Repository: ripple/explorer (ts)

Issue:
Add language picker
Add language picker back. @develoQ has put in a PR (#553) to update the Japanese translations.

Recently the language picking code was removed as it had been hidden from the user for over 3 years.  We need to add back the capability to change to Japanese and also add fallbacks for any values that are not yet translated so that keys arent shown when there is no value for them.

Pull request:
feat: network picker redesign and language picker
## High Level Overview of Change

- Redesigned network picker
- Add more network picker tests
- Add language picker with support for Japanese.
- Cleanup i18next configuration
- Set foundation to add localStorage powered custom networks

Closes #554 and sets up #621

### Context of Change

In relation to #621 this makes the top part of the header use flex box to make it easier to add additional items such as connected node info.

### Type of Change

- [x] New feature (non-breaking change which adds functionality)
- [x] Refactor (non-breaking change that only restructures code)
- [x] Tests (You added tests for code that already exists, or your new feature included in this PR)

### TypeScript/Hooks Update

- [x] Updated files to React Hooks
- [x] Updated files to TypeScript

## Before / After

### Network Picker

#### Closed

##### Before
![Mobile - Before](https://github.com/ripple/explorer/assets/464895/37784a3b-a92f-4233-bac2-ea97415502a7)

##### After
![Mobile - After](https://github.com/ripple/explorer/assets/464895/a726398f-a76e-47a7-b91d-0bed5211bb02)

#### Open

##### Before
![Network Picker Open - Before](https://github.com/ripple/explorer/assets/464895/5fb44f76-11c7-46fe-982e-beb5ac21eb92)
![Custom Network Picker - Before](https://github.com/ripple/explorer/assets/464895/73705c7f-25d4-4307-884d-df48e86fa4f2)

##### After
![Network Picker Open - After](https://github.com/ripple/explorer/assets/464895/2f4f0275-5d6c-459a-a515-f1586599459f)
![Custom Network Picker - After](https://github.com/ripple/explorer/assets/464895/05519000-6963-44b6-8bf7-44fd1c5b7163)

#### Mobile Open

##### Before
![Mobile Network Picker Open - Before](https://github.com/ripple/explorer/assets/464895/e1001e94-0cf6-483f-839d-d038a304632e)
![Mobile Custom Network Picker Open - Before](https://github.com/ripple/explorer/assets/464895/03274a3e-f545-4c27-957d-f0dd0dc72018)

##### After
![Mobile Network Picker Open - After](https://github.com/ripple/explorer/assets/464895/0d6952cb-5ee3-4500-8b78-8a709169bd5f)
![Mobile Custom Network Picker Open - After](https://github.com/ripple/explorer/assets/464895/48cf18c7-5d17-4f3e-997a-7756b9449024)

###  Language Picker
![Mobile Language Picker Open - After](https://github.com/ripple/explorer/assets/464895/633a41d6-bd22-403d-960b-c1b925e16abc)
![Language Picker Open - After](https://github.com/ripple/explorer/assets/464895/6ca917c4-c1c5-405c-a760-1b7e05039ac2)

Patch:
diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json
index 3dc26a4d..c315646c 100644
--- a/public/locales/en-US/translations.json
+++ b/public/locales/en-US/translations.json
@@ -5,18 +5,16 @@
   "assets.issued_tab_title": "Issued Tokens",
   "assets.no_issued_message": "No tokens found.",
   "assets.no_nfts_message": "No NFTs found.",
-  "testnet_data": "testnet data",
-  "mainnet_data": "live data",
-  "devnet_data": "devnet data",
-  "amm_data": "AMM-Devnet data",
-  "custom_network_data": "Custom Network Data",
-  "devnet_explorer": "devnet explorer",
-  "testnet_explorer": "testnet explorer",
-  "mainnet_explorer": "mainnet explorer",
+  "network": "Network",
+  "network_name": "Unknown Network",
+  "network_name_testnet": "Testnet",
+  "network_name_mainnet": "Mainnet",
+  "network_name_devnet": "Devnet",
+  "network_name_amm": "AMM-Devnet",
+  "network_name_custom": "Custom",
   "app.meta.description": "XRPL Network Explorer",
   "app.meta.author": "Ripple",
   "explorer": "Explorer",
-  "network": "Network",
   "xrpl_org": "XRPL.org",
   "github": "GitHub",
   "header.search.placeholder": "Search by Address, Ledger or Txn",
@@ -450,5 +448,7 @@
   "network_cannot_be_crawled": "This network cannot be crawled",
   "check_crawl_existed": "Please contact the operator to make sure they have /crawl accessible or a vl set.",
   "peer_crawled_context": "For more context, see https://xrpl.org/peer-crawler.html",
-  "xchainbridge": "XChainBridge"
+  "xchainbridge": "XChainBridge",
+  "language_en-US": "English",
+  "language_ja-JP": "Japanese"
 }
diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json
index 42ce7b9b..b690bbb7 100644
--- a/public/locales/ja-JP/translations.json
+++ b/public/locales/ja-JP/translations.json
@@ -4,14 +4,11 @@
   "assets.issued_tab_title": "発行済み通貨",
   "assets.no_issued_message": "トークンが見つかりません。",
   "assets.no_nfts_message": "NFTが見つかりません",
-  "testnet_data": "testnetデータ",
-  "mainnet_data": "liveデータ",
-  "devnet_data": "devnetデータ",
-  "amm_data": "AMM-Devnetデータ",
-  "custom_network_data": "カスタムネットワークデータ",
-  "devnet_explorer": "devnetエクスプローラ",
-  "testnet_explorer": "testnetエクスプローラ",
-  "mainnet_explorer": "mainnetエクスプローラ",
+  "network_name_testnet": "testnetデータ",
+  "network_name_mainnet": "liveデータ",
+  "network_name_devnet": "devnetデータ",
+  "network_name_amm": "AMM-Devnetデータ",
+  "network_name_custom": "カスタムネットワークデータ",
   "app.meta.description": "XRPLネットワークエクスプローラ",
   "app.meta.author": "Ripple",
   "explorer": "エクスプローラ",
diff --git a/src/containers/App/index.tsx b/src/containers/App/index.tsx
index be745941..15ceda44 100644
--- a/src/containers/App/index.tsx
+++ b/src/containers/App/index.tsx
@@ -2,7 +2,6 @@ import { Switch, Route } from 'react-router-dom'
 import { Helmet, HelmetProvider } from 'react-helmet-async'
 import { useTranslation } from 'react-i18next'
 import Footer from '../Footer'
-import Banner from '../Header/Banner' // included here for spacing
 import './app.scss'
 import { App } from './App'
 import NoMatch from '../NoMatch'
@@ -21,7 +20,6 @@ export const AppWrapper = () => {
             <meta name="description" content={t('app.meta.description')} />
             <meta name="author" content={t('app.meta.author')} />
           </Helmet>
-          <Banner />
           <Switch>
             <Route path={path} component={App} />
             {mode === 'custom' && (
diff --git a/src/containers/Header/LanguagePicker/LanguagePicker.scss b/src/containers/Header/LanguagePicker/LanguagePicker.scss
new file mode 100644
index 00000000..b3513de9
--- /dev/null
+++ b/src/containers/Header/LanguagePicker/LanguagePicker.scss
@@ -0,0 +1,13 @@
+@import '../../shared/css/variables';
+
+.language-picker {
+  margin-left: auto;
+
+  .dropdown-toggle {
+    border-color: transparent;
+  }
+
+  .arrow {
+    color: $blue-purple-40;
+  }
+}
diff --git a/src/containers/Header/LanguagePicker/LanguagePicker.tsx b/src/containers/Header/LanguagePicker/LanguagePicker.tsx
new file mode 100644
index 00000000..a9423666
--- /dev/null
+++ b/src/containers/Header/LanguagePicker/LanguagePicker.tsx
@@ -0,0 +1,38 @@
+import { useTranslation } from 'react-i18next'
+import { useLanguage } from '../../shared/hooks'
+import { Dropdown, DropdownItem } from '../../shared/components/Dropdown'
+import './LanguagePicker.scss'
+import { supportedLanguages } from '../../../i18n/baseConfig'
+
+export const LanguagePicker = () => {
+  const { t, i18n } = useTranslation()
+  const currentLanguage = useLanguage()
+
+  const handleLanguageClick = (language: string) => () => {
+    i18n.changeLanguage(language)
+  }
+
+  return (
+    <div className="language-picker">
+      <Dropdown
+        title={t(`language`, { context: currentLanguage, defaultValue: '' })}
+        className="dropdown-right"
+      >
+        {supportedLanguages
+          .filter((language) => language !== currentLanguage)
+          .map((language) => (
+            <DropdownItem
+              key={language}
+              className={`language-picker-${language}`}
+              handler={handleLanguageClick(language)}
+            >
+              {t(`language`, {
+                context: language,
+                defaultValue: '',
+              })}
+            </DropdownItem>
+          ))}
+      </Dropdown>
+    </div>
+  )
+}
diff --git a/src/containers/Header/NavigationMenu/NavigationMenu.scss b/src/containers/Header/NavigationMenu/NavigationMenu.scss
index 24bfa7a7..47fc7030 100644
--- a/src/containers/Header/NavigationMenu/NavigationMenu.scss
+++ b/src/containers/Header/NavigationMenu/NavigationMenu.scss
@@ -153,17 +153,17 @@ $menu-toggle-line-length: $menu-toggle-size - $menu-toggle-line-spacing;
     .selected .nav-link {
       border-left-color: #32e685;
     }
-  }
 
-  .dropdown {
-    display: block;
-  }
+    .dropdown {
+      display: block;
+    }
 
-  .dropdown-menu {
-    position: static;
-    padding-left: 15px;
-    border: none;
-    margin: 0;
+    .dropdown-menu {
+      position: static;
+      padding-left: 15px;
+      border: none;
+      margin: 0;
+    }
   }
 
   .dropdown-item:hover {
diff --git a/src/containers/Header/NetworkPicker/NetworkPicker.scss b/src/containers/Header/NetworkPicker/NetworkPicker.scss
new file mode 100644
index 00000000..ceeb9201
--- /dev/null
+++ b/src/containers/Header/NetworkPicker/NetworkPicker.scss
@@ -0,0 +1,60 @@
+@import '../../shared/css/variables';
+
+@mixin dropdown-network-item(
+  $background,
+  $background-hover: $background,
+  $border: $background,
+  $color: $black
+) {
+  .dropdown-toggle {
+    border-color: $border;
+    background: $background;
+    color: $color;
+
+    &:hover {
+      background: $background-hover;
+    }
+
+    .arrow {
+      color: $color;
+    }
+  }
+}
+
+.network {
+  min-width: 0;
+
+  .dropdown-toggle {
+    width: 100%;
+    max-width: 100%;
+    overflow-x: clip;
+  }
+
+  .dropdown-toggle-text {
+    @extend %truncate;
+  }
+
+  .dropdown-menu {
+    min-width: max(100%, 250px);
+  }
+
+  &.network-mainnet {
+    @include dropdown-network-item($white, $black-20);
+  }
+
+  &.network-testnet {
+    @include dropdown-network-item($testnet, $orange-60);
+  }
+
+  &.network-devnet {
+    @include dropdown-network-item($devnet, $blue-purple-40);
+  }
+
+  &.network-amm {
+    @include dropdown-network-item($amm, $blue-60);
+  }
+
+  &.network-custom {
+    @include dropdown-network-item($custom, $yellow-60);
+  }
+}
diff --git a/src/containers/Header/NetworkPicker/NetworkPicker.tsx b/src/containers/Header/NetworkPicker/NetworkPicker.tsx
new file mode 100644
index 00000000..ef3a8f1e
--- /dev/null
+++ b/src/containers/Header/NetworkPicker/NetworkPicker.tsx
@@ -0,0 +1,131 @@
+import { useContext, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Dropdown, DropdownItem } from '../../shared/components/Dropdown'
+import SocketContext from '../../shared/SocketContext'
+import { ANALYTIC_TYPES, analytics } from '../../shared/utils'
+import './NetworkPicker.scss'
+
+export interface Network {
+  network: string
+  title: string
+  url: string
+}
+
+const CUSTOM_NETWORK_BASE_LINK = process.env.VITE_CUSTOMNETWORK_LINK
+const STATIC_ENV_LINKS: Record<string, string | undefined> = {
+  mainnet: process.env.VITE_MAINNET_LINK,
+  testnet: process.env.VITE_TESTNET_LINK,
+  devnet: process.env.VITE_DEVNET_LINK,
+  amm: process.env.VITE_AMM_LINK,
+}
+const currentMode: string = process.env.VITE_ENVIRONMENT || 'mainnet'
+
+export const NetworkPicker = () => {
+  const rippledSocket = useContext(SocketContext)
+  const { t } = useTranslation()
+  const [newRippledUrl, setNewRippledUrl] = useState('')
+
+  const rippledUrl = rippledSocket?.rippledUrl
+  const isCustom = currentMode === 'custom'
+  const inNetwork = !(isCustom && rippledUrl === undefined)
+
+  const getNetworkName = (network: string) =>
+    t(`network_name`, { context: network })
+
+  const getCustomNetworkName = (url) =>
+    `${getNetworkName('custom')}: ${url
+      ?.replace(`${CUSTOM_NETWORK_BASE_LINK || ''}/`, '')
+      .toLowerCase()}`
+
+  const trackNetworkSwitch = (network: Network) => {
+    analytics(ANALYTIC_TYPES.event, {
+      eventCategory: 'mode switch',
+      eventAction: network.url,
+    })
+  }
+
+  const networks = [
+    ...Object.entries(STATIC_ENV_LINKS).map(([network, url]) => ({
+      network,
+      title: getNetworkName(network),
+      url,
+    })),
+  ]
+
+  const handleNetworkClick = (network: Network) => () => {
+    trackNetworkSwitch(network)
+  }
+
+  const networkPickerTitle = !inNetwork
+    ? t('no_network_selected')
+    : getNetworkName(currentMode)
+
+  function renderDropdownItem(network, url, text) {
+    return (
+      <DropdownItem
+        key={url}
+        href={url}
+        className={network}
+        handler={handleNetworkClick(network)}
+      >
+        {text}
+      </DropdownItem>
+    )
+  }
+
+  function renderCustomNetworkInput() {
+    return (
+      <DropdownItem key="new_network">
+        <form onSubmit={onSubmit} className="custom-network-form">
+          <input
+            type="text"
+            placeholder="Add custom network"
+            onChange={(e) => setNewRippledUrl(e.target.value)}
+            autoCorrect="off"
+            autoCapitalize="none"
+            autoComplete="off"
+          />
+        </form>
+      </DropdownItem>
+    )
+  }
+
+  function onSubmit(event) {
+    event.preventDefault()
+    if (rippledUrl != null && newRippledUrl.toLowerCase() === rippledUrl) {
+      return
+    }
+
+    trackNetworkSwitch({
+      network: 'custom',
+      title: `Custom: ${newRippledUrl}`,
+      url: newRippledUrl,
+    })
+    window.location.assign(`${CUSTOM_NETWORK_BASE_LINK}/${newRippledUrl}`)
+  }
+
+  return (
+    <Dropdown
+      title={
+        isCustom && inNetwork
+          ? getCustomNetworkName(rippledUrl)
+          : networkPickerTitle
+      }
+      className={`network network-${currentMode}`}
+    >
+      <>
+        {networks.map(({ network, title, url = '' }) => {
+          if (
+            network === currentMode ||
+            window.location.href?.indexOf(url) === 0
+          ) {
+            return null // don't render if we are in that network
+          }
+
+          return renderDropdownItem(network, url, title)
+        })}
+        {renderCustomNetworkInput()}
+      </>
+    </Dropdown>
+  )
+}
diff --git a/src/containers/Header/NetworkSelector/NetworkSelector.scss b/src/containers/Header/NetworkSelector/NetworkSelector.scss
deleted file mode 100644
index 68e4e238..00000000
--- a/src/containers/Header/NetworkSelector/NetworkSelector.scss
+++ /dev/null
@@ -1,146 +0,0 @@
-@import '../../shared/css/variables';
-
-@mixin dropdown-network-item($background, $border: $black, $color: $white) {
-  background: $background;
-
-  .dropdown {
-    border-color: $border;
-    background: $background;
-    color: $color;
-
-    .item {
-      background: $background;
-    }
-  }
-}
-
-.network {
-  display: flex;
-  height: 23px;
-  padding: 0 16px;
-  margin: 0 -16px;
-
-  .arrow-container {
-    display: flex;
-    width: 100%;
-    align-items: center;
-
-    .arrow {
-      width: 10px;
-      color: $white;
-
-      &.open {
-        transform: rotate(180deg);
-      }
-    }
-  }
-
-  .dropdown {
-    position: relative;
-    z-index: 10000;
-    display: flex;
-    height: 100%;
-    flex-direction: column;
-    flex-shrink: 0;
-    background: $black-100;
-    color: $white;
-    cursor: pointer;
-    font-size: 12px;
-    line-height: 23px;
-    text-transform: capitalize;
-    @include bold;
-
-    img {
-      width: 12px;
-      padding: 2px 2px 2px 10px;
-      margin: 4px 0px;
-      vertical-align: middle;
-    }
-
-    .item {
-      display: none;
-
-      &.input {
-        padding-bottom: 2px;
-      }
-
-      &.selected {
-        display: flex;
-        justify-content: space-between;
-      }
-
-      .check {
-        display: none;
-      }
-
-      span {
-        display: inline;
-      }
-    }
-
-    &.expanded {
-      border: 1px solid $black-100;
-      border-radius: 5px;
-
-      .bg {
-        position: fixed;
-        z-index: -1;
-        top: 0px;
-        left: 0px;
-        width: 100%;
-        height: 100%;
-        opacity: 0;
-      }
-
-      .item {
-        display: flex;
-        align-items: center;
-        justify-content: space-between;
-        padding: 0 8px;
-        background: $black-100;
-
-        &.input {
-          padding: 0 3px;
-        }
-
-        &.selected .check {
-          display: initial;
-        }
-
-        &:hover {
-          color: $black-70;
-        }
-      }
-    }
-  }
-
-  .custom {
-    text-transform: none;
-  }
-
-  &.mainnet {
-    .check path {
-      fill: $white;
-    }
-  }
-
-  &.testnet {
-    @include dropdown-network-item($testnet);
-  }
-
-  &.devnet {
-    @include dropdown-network-item($devnet);
-  }
-
-  &.amm {
-    @include dropdown-network-item($amm);
-  }
-
-  &.custom {
-    @include dropdown-network-item($custom, $custom, $black);
-
-    .arrow path {
-      fill: $black;
-    }
-  }
-}
diff --git a/src/containers/Header/header.scss b/src/containers/Header/header.scss
index 5902f54e..140c659b 100644
--- a/src/containers/Header/header.scss
+++ b/src/containers/Header/header.scss
@@ -8,7 +8,24 @@
   display: flex;
   width: 100%;
   flex-direction: column;
-  padding: 0 16px 16px;
+  padding: 16px;
   background: $black;
   gap: 8px;
+
+  .topbar {
+    display: flex;
+    width: 100%;
+    min-width: 0;
+    max-width: 100%;
+    gap: 0 16px;
+
+    .dropdown-toggle {
+      padding: 8px 12px;
+    }
+
+    .dropdown-menu,
+    .dropdown-item {
+      padding: 8px;
+    }
+  }
 }
diff --git a/src/containers/Header/index.jsx b/src/containers/Header/index.jsx
index 19b54624..27a35bc5 100644
--- a/src/containers/Header/index.jsx
+++ b/src/containers/Header/index.jsx
@@ -1,207 +1,24 @@
 import classnames from 'classnames'
-import { useContext, useState } from 'react'
 import PropTypes from 'prop-types'
-import { useTranslation } from 'react-i18next'
-import { ANALYTIC_TYPES, analytics } from '../shared/utils'
 
 import Banner from './Banner'
-import ArrowIcon from '../shared/images/down_arrow.svg'
-import CheckIcon from '../shared/images/checkmark.svg'
-import SocketContext from '../shared/SocketContext'
 import { NavigationMenu } from './NavigationMenu'
 import { routesConfig } from './routes'
 
 import './header.scss'
-import './NetworkSelector/NetworkSelector.scss'
-import './menu.scss'
-
-const STATIC_ENV_LINKS = {
-  mainnet: process.env.VITE_MAINNET_LINK,
-  testnet: process.env.VITE_TESTNET_LINK,
-  devnet: process.env.VITE_DEVNET_LINK,
-  amm: process.env.VITE_AMM_LINK,
-}
-
-const CUSTOM_NETWORK_BASE_LINK = process.env.VITE_CUSTOMNETWORK_LINK
-
-function isCustomNetwork(mode) {
-  return !Object.keys(STATIC_ENV_LINKS).includes(mode)
-}
-
-function getSocketUrl(socket) {
-  return socket?.endpoint.replace('wss://', '').replace('ws://', '')
-}
-
-export const Header = ({ inNetwork }) => {
-  const rippledSocket = useContext(SocketContext)
-  const [expanded, setExpanded] = useState(false)
-  const { t } = useTranslation()
-
-  function switchMode(desiredLink) {
-    analytics(ANALYTIC_TYPES.event, {
-      eventCategory: 'mode switch',
-      eventAction: desiredLink,
-    })
-    window.location.assign(desiredLink)
-  }
-
-  function toggleExpand(event) {
-    const className = event?.target?.getAttribute('class')
-    if (!(expanded && className === 'custom_network_input'))
-      // don't de-expand if clicking in the textbox
-      setExpanded((prevExpanded) => !prevExpanded)
-  }
-
-  function handleClick(event) {
-    const mode = event.currentTarget.getAttribute('value')
-
-    if (mode === process.env.VITE_ENVIRONMENT) {
-      return
-    }
-
-    const desiredLink = STATIC_ENV_LINKS[mode] ?? process.env.VITE_MAINNET_LINK
-    switchMode(desiredLink)
-  }
-
-  function handleCustomNetworkClick(event) {
-    if (!expanded) {
-      return
-    }
-
-    const currentRippledUrl = getSocketUrl(rippledSocket)
-    const newRippledUrl = event.currentTarget.getAttribute('value')
-
-    if (newRippledUrl.toLowerCase() === currentRippledUrl.toLowerCase()) {
-      return
-    }
-
-    switchMode(`${CUSTOM_NETWORK_BASE_LINK}/${newRippledUrl}`)
-  }
-
-  function onInputKeyDown(event) {
-    if (event.key === 'Enter') {
-      const currentRippledUrl = getSocketUrl(rippledSocket)
-      const rippledUrl = event.currentTarget.value.trim()
-      if (
-        currentRippledUrl != null &&
-        rippledUrl.toLowerCase() === currentRippledUrl.toLowerCase()
-      ) {
-        return
-      }
-      switchMode(`${CUSTOM_NETWORK_BASE_LINK}/${rippledUrl}`)
-    }
-  }
-
-  function renderDropdown(network, clickHandler, classname, text) {
-    return (
-      <div
-        key={network}
-        className={classname}
-        role="menuitem"
-        value={network}
-        onClick={clickHandler}
-        onKeyUp={clickHandler}
-        tabIndex={0}
-      >
-        <span>{text}</span>
-        {/*
-        Adding this space to provide width between name
-        and selected checkmark
-        */}
-        &nbsp;
-        <CheckIcon className="check" desc={network} />
-      </div>
-    )
-  }
-
-  function renderCustomNetworkInput() {
-    return (
-      <div
-        key="new_network"
-        className="item input"
-        value="new_network"
-        role="menuitem"
-        tabIndex={0}
-      >
-        <input
-          className="custom_network_input"
-          type="text"
-          placeholder="Add custom network"
-          onKeyDown={onInputKeyDown}
-        />
-      </div>
-    )
-  }
-
-  const currentMode = process.env.VITE_ENVIRONMENT
-
-  const urlLinkMap = {
-    ...STATIC_ENV_LINKS,
-    // TODO: store previous custom networks in cookies, add them here
-  }
-
-  const rippledUrl = getSocketUrl(rippledSocket)
-
-  if (process.env.VITE_ENVIRONMENT === 'custom') {
-    urlLinkMap[
-      rippledUrl
-    ] = `${process.env.VITE_CUSTOMNETWORK_LINK}${rippledUrl}`
-  }
-
-  return (
-    <header className={classnames('header', !inNetwork && 'header-no-network')}>
-      <div className={`network ${currentMode}`}>
-        <div
-          className={classnames('dropdown', { expanded })}
-          role="menubar"
-          tabIndex={0}
-          onClick={toggleExpand}
-          onKeyUp={toggleExpand}
-        >
-          <div className="bg" />
-          {!inNetwork &&
-            !expanded &&
-            renderDropdown(
-              'no-network',
-              handleCustomNetworkClick,
-              classnames('item', 'custom', { selected: true }),
-              t('no_network_selected'),
-            )}
-          {Object.keys(urlLinkMap).map((network) =>
-            isCustomNetwork(network)
-              ? renderDropdown(
-                  network,
-                  handleCustomNetworkClick,
-                  classnames('item', 'custom', {
-                    selected: network === rippledUrl,
-                  }),
-                  `${t('custom_network_data')}: ${network.toLowerCase()}`,
-                )
-              : renderDropdown(
-                  network,
-                  handleClick,
-                  classnames('item', { selected: currentMode === network }),
-                  t(`${network}_data`),
-                ),
-          )}
-          {renderCustomNetworkInput()}
-        </div>
-        <div
-          className="arrow-container"
-          onClick={toggleExpand}
-          onKeyUp={toggleExpand}
-          role="menubar"
-          tabIndex={0}
-          value="arrow"
-        >
-          <ArrowIcon className={classnames('arrow', { open: expanded })} />
-        </div>
-      </div>
-      <Banner />
-      <NavigationMenu routes={routesConfig} />
-    </header>
-  )
-}
+import { LanguagePicker } from './LanguagePicker/LanguagePicker'
+import { NetworkPicker } from './NetworkPicker/NetworkPicker'
+
+export const Header = ({ inNetwork }) => (
+  <header className={classnames('header', !inNetwork && 'header-no-network')}>
+    <div className="topbar">
+      <NetworkPicker />
+      <LanguagePicker />
+    </div>
+    <Banner />
+    <NavigationMenu routes={routesConfig} />
+  </header>
+)
 
 Header.defaultProps = {
   inNetwork: true,
diff --git a/src/containers/Header/menu.scss b/src/containers/Header/menu.scss
deleted file mode 100644
index cb50128e..00000000
--- a/src/containers/Header/menu.scss
+++ /dev/null
@@ -1,35 +0,0 @@
-@import '../shared/css/variables';
-
-.menu {
-  display: none;
-  margin-bottom: -1px;
-  color: $black-40;
-  font-size: 14px;
-  letter-spacing: 0px;
-  @include medium;
-
-  @include for-size(desktop-up) {
-    display: flex;
-    flex-grow: inherit;
-    align-content: center;
-    justify-content: space-around;
-  }
-
-  .menu-item {
-    display: inline-block;
-    text-align: center;
-    white-space: nowrap;
-
-    :hover,
-    :focus {
-      color: $white;
-    }
-
-    a {
-      display: block;
-      padding: 26px 24px 12px;
-      margin-bottom: -1px;
-      color: inherit;
-    }
-  }
-}
diff --git a/src/containers/shared/SocketContext.tsx b/src/containers/shared/SocketContext.tsx
index 8484fd1b..7f3ac5eb 100644
--- a/src/containers/shared/SocketContext.tsx
+++ b/src/containers/shared/SocketContext.tsx
@@ -5,6 +5,7 @@ const LOCALHOST_URLS = ['localhost', '127.0.0.1', '0.0.0.0']
 
 export interface ExplorerXrplClient extends XrplClient {
   p2pSocket: XrplClient
+  rippledUrl: string | undefined
 }
 
 function isInsecureWs(rippledHost: string | undefined): boolean {
@@ -49,10 +50,12 @@ function getSocket(rippledUrl?: string): ExplorerXrplClient {
         }:${process.env.VITE_RIPPLED_WS_PORT}`,
       ])
     : undefined
+
+  socket.rippledUrl = rippledUrl
   return socket
 }
 
-const SocketContext = createContext<XrplClient>(undefined!)
+const SocketContext = createContext<ExplorerXrplClient>(undefined!)
 
 export type SocketProviderProps = React.PropsWithChildren<{
   children: any
diff --git a/src/containers/shared/components/Dropdown/Dropdown.tsx b/src/containers/shared/components/Dropdown/Dropdown.tsx
index 04a15ece..b525aa47 100644
--- a/src/containers/shared/components/Dropdown/Dropdown.tsx
+++ b/src/containers/shared/components/Dropdown/Dropdown.tsx
@@ -76,7 +76,8 @@ export const Dropdown = ({
         aria-haspopup="true"
         aria-expanded={expanded}
       >
-        {title} <ArrowIcon className="arrow" />
+        <div className="dropdown-toggle-text">{title}</div>{' '}
+        <ArrowIcon className="arrow" />
       </button>
       <div
         className="dropdown-menu"
diff --git a/src/containers/shared/components/Dropdown/dropdown.scss b/src/containers/shared/components/Dropdown/dropdown.scss
index ab3b6a96..5d23922e 100644
--- a/src/containers/shared/components/Dropdown/dropdown.scss
+++ b/src/containers/shared/components/Dropdown/dropdown.scss
@@ -11,7 +11,7 @@
   display: flex;
   align-items: center;
   font-weight: 700;
-  gap: 0 24px;
+  gap: 0 8px;
 
   .arrow {
     height: 1em;
@@ -23,6 +23,7 @@
   position: absolute;
   z-index: 100;
   display: none;
+  overflow: hidden;
   min-width: max(100%, 160px);
   padding: 8px;
   border: 1px solid $black-80;
diff --git a/src/containers/shared/components/Notification/styles.scss b/src/containers/shared/components/Notification/styles.scss
index be156974..9844d232 100644
--- a/src/containers/shared/components/Notification/styles.scss
+++ b/src/containers/shared/components/Notification/styles.scss
@@ -3,7 +3,7 @@
 .notification {
   border: 1px solid;
   border-radius: 4px;
-  margin: 12px 24px;
+  margin: 12px 0;
   background: $blue;
   color: $white;
   font-size: 14px;
diff --git a/src/containers/shared/css/variables.scss b/src/containers/shared/css/variables.scss
index 4a3cdc5f..ca585928 100644
--- a/src/containers/shared/css/variables.scss
+++ b/src/containers/shared/css/variables.scss
@@ -111,8 +111,8 @@ $white: #fff;
 $white-transparent: rgb(255 255 255 / 75%);
 
 // Networks
-$testnet: $orange-60;
-$devnet: $blue-60;
+$testnet: $orange;
+$devnet: $blue-purple-30;
 $custom: $yellow-50;
 
 // Feature Sets
diff --git a/src/containers/shared/images/down_arrow.svg b/src/containers/shared/images/down_arrow.svg
index 5e50f874..3a4430e1 100644
--- a/src/containers/shared/images/down_arrow.svg
+++ b/src/containers/shared/images/down_arrow.svg
@@ -1,6 +1,5 @@
 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21">
   <g transform="translate(-254 -232)">
-    <rect id="Rectangle_1" data-name="Rectangle 1" fill="none" width="21" height="21" transform="translate(254 232)"/>
-    <path id="angle-down" fill="white" d="M107.741,10.99l-8.3,8.6a1.318,1.318,0,0,1-1.911,0,1.437,1.437,0,0,1,0-1.981L104.875,10,97.535,2.391a1.437,1.437,0,0,1,0-1.981,1.318,1.318,0,0,1,1.911,0l8.3,8.6a1.436,1.436,0,0,1,0,1.98Z" transform="translate(274.5 139.862) rotate(90)"/>
+    <path id="angle-down" fill="currentColor" d="M107.741,10.99l-8.3,8.6a1.318,1.318,0,0,1-1.911,0,1.437,1.437,0,0,1,0-1.981L104.875,10,97.535,2.391a1.437,1.437,0,0,1,0-1.981,1.318,1.318,0,0,1,1.911,0l8.3,8.6a1.436,1.436,0,0,1,0,1.98Z" transform="translate(274.5 139.862) rotate(90)"/>
   </g>
 </svg>
diff --git a/src/i18n/baseConfig.ts b/src/i18n/baseConfig.ts
index 498eb2be..897091f9 100644
--- a/src/i18n/baseConfig.ts
+++ b/src/i18n/baseConfig.ts
@@ -1,9 +1,8 @@
-import Backend from 'i18next-http-backend'
-import LanguageDetector from 'i18next-browser-languagedetector'
-import i18n, { InitOptions } from 'i18next'
-import { configureFormatters } from './formatters'
+import { InitOptions } from 'i18next'
 
-const options: InitOptions = {
+export const supportedLanguages = ['en-US', 'ja-JP']
+
+export const options: InitOptions = {
   returnNull: false,
   debug: process.env.NODE_ENV === 'development',
   fallbackLng: 'en-US',
@@ -14,6 +13,7 @@ const options: InitOptions = {
     escapeValue: false, // not needed for react!!
     formatSeparator: ',',
   },
+  supportedLngs: supportedLanguages,
   react: {
     useSuspense: true,
   },
@@ -22,8 +22,3 @@ const options: InitOptions = {
   },
   load: 'currentOnly',
 }
-
-i18n.use(Backend).use(LanguageDetector).init(options)
-configureFormatters(i18n)
-
-export default i18n
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index e83a005e..7c415259 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -1,3 +1,13 @@
-import i18n from './baseConfig'
+import i18n from 'i18next'
+import Backend from 'i18next-http-backend'
+import LanguageDetector from 'i18next-browser-languagedetector'
+import { configureFormatters } from './formatters'
+import { options } from './baseConfig'
+
+i18n
+  .use(Backend)
+  .use(LanguageDetector)
+  .init({ ...options, detector: { excludeCacheFor: [] } } as any)
+configureFormatters(i18n)
 
 export default i18n


Test patch:
diff --git a/src/containers/Header/LanguagePicker/test/LanguagePicker.test.tsx b/src/containers/Header/LanguagePicker/test/LanguagePicker.test.tsx
new file mode 100644
index 00000000..2b84c663
--- /dev/null
+++ b/src/containers/Header/LanguagePicker/test/LanguagePicker.test.tsx
@@ -0,0 +1,22 @@
+import { mount } from 'enzyme'
+import i18next from 'i18next'
+import { I18nextProvider } from 'react-i18next'
+import { LanguagePicker } from '../LanguagePicker'
+import testConfigEnglish from '../../../../i18n/testConfigEnglish'
+
+describe('LanguagePicker component', () => {
+  it('should switch language', () => {
+    const wrapper = mount(
+      <I18nextProvider i18n={testConfigEnglish}>
+        <LanguagePicker />
+      </I18nextProvider>,
+    )
+
+    wrapper.find('.dropdown-toggle').simulate('click')
+    wrapper.find('.dropdown-item.language-picker-ja-JP').simulate('click')
+
+    expect(i18next.language).toEqual('ja-JP')
+
+    wrapper.unmount()
+  })
+})
diff --git a/src/containers/Header/NetworkPicker/test/NetworkPicker.test.tsx b/src/containers/Header/NetworkPicker/test/NetworkPicker.test.tsx
new file mode 100644
index 00000000..694b3d04
--- /dev/null
+++ b/src/containers/Header/NetworkPicker/test/NetworkPicker.test.tsx
@@ -0,0 +1,117 @@
+import { mount } from 'enzyme'
+import { I18nextProvider } from 'react-i18next'
+import { BrowserRouter as Router } from 'react-router-dom'
+import i18n from '../../../../i18n/testConfigEnglish'
+import SocketContext from '../../../shared/SocketContext'
+import MockWsClient from '../../../test/mockWsClient'
+
+describe('NetworkPicker component', () => {
+  let client
+  const { location } = window
+  const mockedFunction = jest.fn()
+  const oldEnvs = process.env
+
+  const createWrapper = () => {
+    let Picker
+
+    // Needed to test different env variable values.
+    jest.isolateModules(() => {
+      ;({ NetworkPicker: Picker } = jest.requireActual('../NetworkPicker'))
+    })
+
+    return mount(
+      <I18nextProvider i18n={i18n}>
+        <Router>
+          <SocketContext.Provider value={client}>
+            <Picker />
+          </SocketContext.Provider>
+        </Router>
+      </I18nextProvider>,
+    )
+  }
+
+  beforeEach(() => {
+    // @ts-ignore -- typescript does not like mocking window
+    delete window.location
+    // @ts-ignore -- typescript does not like mocking window
+    window.location = { assign: mockedFunction }
+    process.env = {
+      ...oldEnvs,
+      VITE_ENVIRONMENT: 'mainnet',
+      VITE_MAINNET_LINK: 'https://livenet.xrpl.org',
+      VITE_TESTNET_LINK: 'https://testnet.xrpl.org',
+      VITE_DEVNET_LINK: 'https://devnet.xrpl.org',
+      VITE_AMM_LINK: 'https://amm-devnet.xrpl.org',
+    }
+    client = new MockWsClient()
+  })
+
+  afterEach(() => {
+    client.close()
+    window.location = location
+    process.env = oldEnvs
+  })
+
+  it('dropdown expands', () => {
+    const wrapper = createWrapper()
+    expect(wrapper.find('.network .dropdown')).toExist()
+    expect(wrapper.find('.network .dropdown')).not.toHaveClassName(
+      '.dropdown-expanded',
+    )
+
+    wrapper.find('.network .dropdown-toggle').simulate('click')
+    expect(wrapper.find('.network .dropdown')).toHaveClassName(
+      '.dropdown-expanded',
+    )
+    wrapper.unmount()
+  })
+
+  it('direct to other networks', () => {
+    const wrapper = createWrapper()
+
+    // expand dropdown
+    expect(wrapper.find('.network .dropdown')).toExist()
+    wrapper.find('.network .dropdown-toggle').simulate('click')
+    expect(wrapper.find('.dropdown-item.mainnet')).not.toExist() // don't show the current network
+    expect(wrapper.find('.dropdown-item.testnet')).toExist()
+
+    // test clicking on testnet
+    wrapper.find('.dropdown-item.testnet').simulate('click')
+    expect(wrapper.find('.dropdown-item.testnet')).toHaveProp(
+      'href',
+      process.env.VITE_TESTNET_LINK,
+    )
+
+    expect(wrapper.find('.dropdown-item.devnet')).toHaveProp(
+      'href',
+      process.env.VITE_DEVNET_LINK,
+    )
+
+    wrapper.unmount()
+  })
+
+  it('redirect on custom network input works', () => {
+    const wrapper = createWrapper()
+
+    const customInput = wrapper.find('[className="custom-network-form"] input')
+    customInput.simulate('change', { target: { value: 'custom_url' } })
+    customInput.simulate('submit', { keyCode: 13 })
+
+    expect(mockedFunction).toBeCalledWith(
+      `${process.env.VITE_CUSTOMNETWORK_LINK}/custom_url`,
+    )
+
+    wrapper.unmount()
+  })
+
+  it('shows no network selected in custom mode with no network', () => {
+    process.env.VITE_ENVIRONMENT = 'custom'
+    const wrapper = createWrapper()
+
+    // expand dropdown
+    expect(wrapper.find('.network-custom')).toExist()
+    expect(wrapper.find('.network-custom .dropdown-toggle')).toIncludeText(
+      'No Custom Network Selected',
+    )
+  })
+})
diff --git a/src/containers/Header/test/Header.test.js b/src/containers/Header/test/Header.test.js
deleted file mode 100644
index 08035337..00000000
--- a/src/containers/Header/test/Header.test.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import { mount } from 'enzyme'
-import { I18nextProvider } from 'react-i18next'
-import { BrowserRouter as Router } from 'react-router-dom'
-import configureMockStore from 'redux-mock-store'
-import thunk from 'redux-thunk'
-import { Provider } from 'react-redux'
-import { initialState } from '../../../rootReducer'
-import i18n from '../../../i18n/testConfig'
-import { Header } from '../index'
-import SocketContext from '../../shared/SocketContext'
-import MockWsClient from '../../test/mockWsClient'
-
-describe('Header component', () => {
-  let client
-  // Redux setup required for <Banner>
-  const middlewares = [thunk]
-  const mockStore = configureMockStore(middlewares)
-  const createWrapper = () => {
-    const store = mockStore({ ...initialState })
-    return mount(
-      <I18nextProvider i18n={i18n}>
-        <Router>
-          <Provider store={store}>
-            <SocketContext.Provider value={client}>
-              <Header />
-            </SocketContext.Provider>
-          </Provider>
-        </Router>
-      </I18nextProvider>,
-    )
-  }
-
-  beforeEach(() => {
-    client = new MockWsClient()
-  })
-
-  afterEach(() => {
-    client.close()
-  })
-
-  it('renders without crashing', () => {
-    const wrapper = createWrapper()
-    wrapper.unmount()
-  })
-
-  it('renders all parts', () => {
-    const wrapper = createWrapper()
-    expect(wrapper.find('.search').length).toEqual(1)
-    expect(wrapper.find('.navbar-brand').hostNodes().length).toEqual(1)
-    expect(wrapper.find('.network').length).toEqual(1)
-    wrapper.unmount()
-  })
-
-  it('dropdown expands', () => {
-    const wrapper = createWrapper()
-    expect(wrapper.find('.dropdown').length).toEqual(1)
-    expect(wrapper.find('[className="dropdown expanded"]').length).toEqual(0)
-
-    wrapper.find('.dropdown').simulate('click')
-    expect(wrapper.find('[className="dropdown expanded"]').length).toEqual(1)
-    wrapper.unmount()
-  })
-
-  describe('test redirects', () => {
-    const { location } = window
-    const mockedFunction = jest.fn()
-    const oldEnvs = process.env
-
-    beforeEach(() => {
-      delete window.location
-      window.location = { assign: mockedFunction }
-      process.env = { ...oldEnvs, VITE_ENVIRONMENT: 'mainnet' }
-    })
-
-    afterEach(() => {
-      window.location = location
-      process.env = oldEnvs
-    })
-
-    it('redirect works', () => {
-      const wrapper = createWrapper()
-
-      // expand dropdown
-      expect(wrapper.find('.dropdown').length).toEqual(1)
-      wrapper.find('.dropdown').simulate('click')
-      expect(wrapper.find('[value="mainnet"]').length).toEqual(1)
-
-      // test clicking on mainnet
-      wrapper.find('[value="mainnet"]').simulate('click')
-      expect(mockedFunction).not.toHaveBeenCalled()
-
-      // test clicking on testnet
-      wrapper.find('[value="testnet"]').simulate('click')
-      expect(mockedFunction).toBeCalledWith(process.env.VITE_TESTNET_LINK)
-
-      wrapper.unmount()
-    })
-
-    it('redirect on custom network input works', () => {
-      const wrapper = createWrapper()
-
-      const customInput = wrapper.find('[className="custom_network_input"]')
-      customInput.prop('onKeyDown')({
-        key: 'Enter',
-        currentTarget: { value: 'custom_url' },
-      })
-      expect(mockedFunction).toBeCalledWith(
-        `${process.env.VITE_CUSTOMNETWORK_LINK}/custom_url`,
-      )
-
-      wrapper.unmount()
-    })
-  })
-})
diff --git a/src/containers/Header/test/Header.test.tsx b/src/containers/Header/test/Header.test.tsx
new file mode 100644
index 00000000..c1c715fa
--- /dev/null
+++ b/src/containers/Header/test/Header.test.tsx
@@ -0,0 +1,53 @@
+import { mount } from 'enzyme'
+import { I18nextProvider } from 'react-i18next'
+import { BrowserRouter as Router } from 'react-router-dom'
+import configureMockStore from 'redux-mock-store'
+import thunk from 'redux-thunk'
+import { Provider } from 'react-redux'
+import { initialState } from '../../../rootReducer'
+import i18n from '../../../i18n/testConfigEnglish'
+import SocketContext from '../../shared/SocketContext'
+import MockWsClient from '../../test/mockWsClient'
+import { Header } from '../index'
+
+describe('Header component', () => {
+  let client
+  // Redux setup required for <Banner>
+  const middlewares = [thunk]
+  const mockStore = configureMockStore(middlewares)
+  const createWrapper = () => {
+    const store = mockStore({ ...initialState })
+    return mount(
+      <I18nextProvider i18n={i18n}>
+        <Router>
+          <Provider store={store}>
+            <SocketContext.Provider value={client}>
+              <Header />
+            </SocketContext.Provider>
+          </Provider>
+        </Router>
+      </I18nextProvider>,
+    )
+  }
+
+  beforeEach(() => {
+    client = new MockWsClient()
+  })
+
+  afterEach(() => {
+    client.close()
+  })
+
+  it('renders without crashing', () => {
+    const wrapper = createWrapper()
+    wrapper.unmount()
+  })
+
+  it('renders all parts', () => {
+    const wrapper = createWrapper()
+    expect(wrapper.find('.search').length).toEqual(1)
+    expect(wrapper.find('.navbar-brand').hostNodes().length).toEqual(1)
+    expect(wrapper.find('.network').hostNodes().length).toEqual(1)
+    wrapper.unmount()
+  })
+})
diff --git a/src/containers/Header/test/mockRoutes.js b/src/containers/Header/test/mockRoutes.js
deleted file mode 100644
index 6f43adc0..00000000
--- a/src/containers/Header/test/mockRoutes.js
+++ /dev/null
@@ -1,25 +0,0 @@
-const routes = [
-  {
-    path: '/',
-    title: 'home',
-  },
-  {
-    title: 'explorer',
-    children: [
-      {
-        path: '/ledgers',
-        title: 'ledgers',
-      },
-      {
-        path: '/transactions',
-        title: 'transactions',
-      },
-    ],
-  },
-  {
-    path: '/nodes',
-    title: 'network',
-  },
-]
-
-export default routes
diff --git a/src/containers/NoMatch/test/NoMatch.test.tsx b/src/containers/NoMatch/test/NoMatch.test.tsx
index 9681672a..9eb8f9c9 100644
--- a/src/containers/NoMatch/test/NoMatch.test.tsx
+++ b/src/containers/NoMatch/test/NoMatch.test.tsx
@@ -1,15 +1,14 @@
 import { mount } from 'enzyme'
 import { I18nextProvider } from 'react-i18next'
-import { XrplClient } from 'xrpl-client'
 import MockWsClient from '../../test/mockWsClient'
-import SocketContext from '../../shared/SocketContext'
+import SocketContext, { ExplorerXrplClient } from '../../shared/SocketContext'
 import i18n from '../../../i18n/testConfigEnglish'
 import NoMatch from '../index'
 
 /* eslint-disable react/jsx-props-no-spreading */
 describe('NoMatch container', () => {
   const createWrapper = (props = {}) => {
-    const client = new MockWsClient() as unknown as XrplClient
+    const client = new MockWsClient() as unknown as ExplorerXrplClient
 
     return mount(
       <I18nextProvider i18n={i18n}>
diff --git a/src/i18n/testConfig.ts b/src/i18n/testConfig.ts
index 65f91867..e8dfbd05 100644
--- a/src/i18n/testConfig.ts
+++ b/src/i18n/testConfig.ts
@@ -1,21 +1,16 @@
 import i18n from 'i18next'
+import { options } from './baseConfig'
 import { configureFormatters } from './formatters'
 
 i18n.init({
+  ...options,
   fallbackLng: 'cimode',
   debug: false,
-  saveMissing: false,
-
-  interpolation: {
-    escapeValue: false, // not needed for react!!
-  },
-
-  // react i18next special options (optional)
   react: {
-    nsMode: 'fallback', // set it to fallback to let passed namespaces to translated hoc act as fallbacks
     useSuspense: false,
   },
 })
+
 configureFormatters(i18n)
 
 export default i18n
diff --git a/src/i18n/testConfigEnglish.ts b/src/i18n/testConfigEnglish.ts
index 941cbb04..e041dd9c 100644
--- a/src/i18n/testConfigEnglish.ts
+++ b/src/i18n/testConfigEnglish.ts
@@ -1,5 +1,5 @@
 import i18n from './testConfig'
-import translation from '../../public/locales/en-US/translations.json'
+import translations from '../../public/locales/en-US/translations.json'
 
 // Configuration which hardcodes translation to english which helps with complex interpolations
 // This is in a separate file until all tests can be switched over
@@ -7,7 +7,7 @@ i18n.init({
   lng: 'en-US',
   resources: {
     'en-US': {
-      translation,
+      translations,
     },
   },
 })