Set styles for 'Select' component

Change-Id: I279d28e478a3d63b6d620f5362580854a0e62b3f
diff --git a/client/src/components/CustomSelect.tsx b/client/src/components/CustomSelect.tsx
index fc377db..c696eab 100644
--- a/client/src/components/CustomSelect.tsx
+++ b/client/src/components/CustomSelect.tsx
@@ -15,29 +15,139 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
+import { Box, InputBase, InputBaseProps, styled, useAutocomplete } from '@mui/material';
+import { MUIStyledCommonProps } from '@mui/system';
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
 
-import { MenuItem, MenuItemProps, Select, SelectProps } from '@mui/material';
+import { ArrowHeadDown, ArrowHeadUp } from './SvgIcon';
 
-export interface CustomSelectOption {
+export interface SelectOption<Payload> {
   label: string;
-  value: MenuItemProps['value'];
+  payload: Payload;
 }
 
-export interface CustomSelectProps extends Omit<SelectProps, 'label'> {
-  value: CustomSelectOption['value'];
-  options: CustomSelectOption[];
-  onChange: SelectProps['onChange'];
-}
+const width = '250px';
 
-const CustomSelect = ({ value, options, onChange }: CustomSelectProps) => {
+type InputProps = InputBaseProps & {
+  popupOpen: boolean; // Sometimes the popover is opened but the Input is not focused
+};
+
+const Input = styled(({ popupOpen, ...props }: InputProps) => <InputBase {...props} />)(({ theme, popupOpen }) => ({
+  width,
+  height: '46px',
+  borderRadius: '5px',
+  border: `1px solid ${popupOpen ? theme.palette.primary.dark : '#0056995C'}`,
+  boxSizing: 'border-box',
+  fontSize: '15px',
+  padding: '16px',
+  color: theme.palette.primary.dark,
+  fontWeight: 'medium',
+  '&.Mui-focused': {
+    // Sometimes the popover is closed but the Input is still focused
+    borderColor: theme.palette.primary.dark,
+    color: 'black',
+  },
+  '&::placeholder': {
+    color: '#666666',
+  },
+  '&.MuiInputBase-sizeSmall': {
+    height: '36px',
+  },
+}));
+
+const Listbox = styled('ul')(({ theme }) => ({
+  width,
+  maxHeight: '230px',
+  boxSizing: 'border-box',
+  margin: 0,
+  padding: 0,
+  paddingTop: '5px',
+  paddingBottom: '8.5px',
+  zIndex: 1,
+  position: 'absolute',
+  top: '-5px',
+  listStyle: 'none',
+  backgroundColor: 'white',
+  overflow: 'auto',
+  border: `1px solid ${theme.palette.primary.dark}`,
+  borderEndEndRadius: '5px',
+  borderEndStartRadius: '5px',
+  borderTop: 'none',
+}));
+
+const Option = styled('li')(({ theme }) => ({
+  paddingLeft: '16px',
+  paddingRight: '16px',
+  paddingTop: '7.5px',
+  paddingBottom: '7.5px',
+  fontSize: '15px',
+  '&.Mui-focused': {
+    backgroundColor: theme.palette.primary.light,
+    color: theme.palette.primary.dark,
+    cursor: 'pointer',
+  },
+}));
+
+const getArrowStyles = ({ theme }: MUIStyledCommonProps) => ({
+  height: '6px',
+  color: theme?.palette.primary.dark,
+  '&:hover': {
+    cursor: 'pointer',
+  },
+});
+const StyledArrowHeadUp = styled(ArrowHeadUp)(getArrowStyles);
+const StyledArrowHeadDown = styled(ArrowHeadDown)(getArrowStyles);
+
+export type CustomSelectProps<OptionPayload> = {
+  option?: SelectOption<OptionPayload>;
+  options: SelectOption<OptionPayload>[];
+  onChange: (newValue: SelectOption<OptionPayload> | null) => void;
+  size?: 'small' | 'medium';
+};
+
+export const CustomSelect = <OptionPayload,>({ option, options, onChange, size }: CustomSelectProps<OptionPayload>) => {
+  const { t } = useTranslation();
+
+  const [open, setOpen] = useState(false);
+
+  const { getRootProps, getInputProps, getListboxProps, getOptionProps, groupedOptions, popupOpen } = useAutocomplete<
+    SelectOption<OptionPayload>
+  >({
+    value: option,
+    options,
+    getOptionLabel: (option) => option.label,
+    onChange: (_, value) => onChange(value),
+    openOnFocus: true,
+    open,
+    onOpen: () => setOpen(true),
+    onClose: () => setOpen(false),
+  });
+
   return (
-    <Select onChange={onChange} value={value}>
-      {options.map((option) => (
-        <MenuItem key={option.label} value={option.value}>
-          {option.label}
-        </MenuItem>
-      ))}
-    </Select>
+    <div>
+      <Box {...getRootProps()}>
+        <Input
+          inputProps={getInputProps()}
+          placeholder={t('select_placeholder')}
+          endAdornment={popupOpen ? <StyledArrowHeadUp /> : <StyledArrowHeadDown />}
+          size={size}
+          // onMouseDown fires before onBlur, contrary to onClick which fires after and causes glitches
+          onMouseDown={() => setOpen(!popupOpen)}
+          popupOpen={popupOpen}
+        />
+      </Box>
+      <Box position="relative">
+        {groupedOptions.length > 0 ? (
+          <Listbox {...getListboxProps()}>
+            {(groupedOptions as SelectOption<OptionPayload>[]).map((option, index) => (
+              <Option key={option.label} {...getOptionProps({ option, index })}>
+                {option.label}
+              </Option>
+            ))}
+          </Listbox>
+        ) : null}
+      </Box>
+    </div>
   );
 };
-export { CustomSelect };
diff --git a/client/src/components/Settings.tsx b/client/src/components/Settings.tsx
index 81333de..ff3ac90 100644
--- a/client/src/components/Settings.tsx
+++ b/client/src/components/Settings.tsx
@@ -55,12 +55,17 @@
   );
 };
 
-interface SettingSelectProps extends Setting, CustomSelectProps {}
+export interface SettingSelectProps<OptionPayload> extends Setting, CustomSelectProps<OptionPayload> {}
 
-export const SettingSelect = ({ options, value, onChange, ...settingBaseProps }: SettingSelectProps) => {
+export const SettingSelect = <OptionPayload,>({
+  options,
+  option,
+  onChange,
+  ...settingBaseProps
+}: SettingSelectProps<OptionPayload>) => {
   return (
     <SettingBase {...settingBaseProps}>
-      <CustomSelect value={value} options={options} onChange={onChange} />
+      <CustomSelect<OptionPayload> option={option} options={options} onChange={onChange} size="small" />
     </SettingBase>
   );
 };
diff --git a/client/src/components/SvgIcon.tsx b/client/src/components/SvgIcon.tsx
index bfce146..0a0e6bb 100644
--- a/client/src/components/SvgIcon.tsx
+++ b/client/src/components/SvgIcon.tsx
@@ -106,6 +106,23 @@
   );
 };
 
+export const ArrowHeadDown = (props: SvgIconProps) => {
+  return (
+    <SvgIcon {...props} viewBox="6 8 12 7.5">
+      <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" />
+    </SvgIcon>
+  );
+};
+
+export const ArrowHeadUp = (props: SvgIconProps) => {
+  return (
+    <SvgIcon {...props} viewBox="6 8 12 7.5">
+      <path fill="none" d="M0 0h24v24H0z" />
+      <path d="m12 8-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z" />
+    </SvgIcon>
+  );
+};
+
 export const AudioCallIcon = (props: SvgIconProps) => {
   return (
     <SvgIcon {...props} viewBox="0 0 15.338 16">
diff --git a/client/src/i18n.ts b/client/src/i18n.ts
index 711fb1a..ae2a78e 100644
--- a/client/src/i18n.ts
+++ b/client/src/i18n.ts
@@ -21,26 +21,28 @@
 import translationEn from './locale/en/translation.json';
 import translationFr from './locale/fr/translation.json';
 
+export type LanguageTag = 'en' | 'fr';
+
 interface LanguageInfo {
-  tag: string;
-  fullName: string;
-  translation: ResourceLanguage;
+  readonly tag: LanguageTag;
+  readonly fullName: string;
+  readonly translation: ResourceLanguage;
 }
 
-export const availableLanguages: LanguageInfo[] = [
-  {
-    tag: 'fr',
-    fullName: 'Français',
-    translation: translationFr,
-  },
+export const languagesInfos: readonly LanguageInfo[] = [
   {
     tag: 'en',
     fullName: 'English',
     translation: translationEn,
   },
-];
+  {
+    tag: 'fr',
+    fullName: 'Français',
+    translation: translationFr,
+  },
+] as const;
 
-const resources = availableLanguages.reduce((resources: Resource, { tag, translation }) => {
+const resources = languagesInfos.reduce((resources: Resource, { tag, translation }) => {
   resources[tag] = { translation };
   return resources;
 }, {});
diff --git a/client/src/locale/en/translation.json b/client/src/locale/en/translation.json
index 4ed3515..921a1da 100644
--- a/client/src/locale/en/translation.json
+++ b/client/src/locale/en/translation.json
@@ -29,6 +29,7 @@
   "conversation_title_three": "{{member0}}, {{member1}} and {{member2}}",
   "conversation_title_four": "{{member0}}, {{member1}}, {{member2}}, +1 other member",
   "conversation_title_more": "{{member0}}, {{member1}}, {{member2}}, +{{excess}} other members",
+  "select_placeholder": "Select an option",
   "dialog_close": "Close",
   "dialog_cancel": "Cancel",
   "Menu": "Menu",
diff --git a/client/src/locale/fr/translation.json b/client/src/locale/fr/translation.json
index c11d93b..7250e0e 100644
--- a/client/src/locale/fr/translation.json
+++ b/client/src/locale/fr/translation.json
@@ -29,6 +29,7 @@
   "conversation_title_three": "{{member0}}, {{member1}} et {{member2}}",
   "conversation_title_four": "{{member0}}, {{member1}}, {{member2}}, +1 autre membre",
   "conversation_title_more": "{{member0}}, {{member1}}, {{member2}}, +{{excess}} autres membres",
+  "select_placeholder": "Sélectionner une option",
   "dialog_close": "Fermer",
   "dialog_cancel": "Annuler",
   "Menu": "Menu",
diff --git a/client/src/pages/GeneralSettings.tsx b/client/src/pages/GeneralSettings.tsx
index 3c7a277..6e651bb 100644
--- a/client/src/pages/GeneralSettings.tsx
+++ b/client/src/pages/GeneralSettings.tsx
@@ -15,13 +15,13 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { SelectChangeEvent, Stack, Typography } from '@mui/material';
-import { useCallback, useContext, useEffect, useState } from 'react';
+import { Stack, Typography } from '@mui/material';
+import { useCallback, useContext, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
-import { SettingSelect, SettingsGroup, SettingSwitch } from '../components/Settings';
+import { SettingSelect, SettingSelectProps, SettingsGroup, SettingSwitch } from '../components/Settings';
 import { CustomThemeContext } from '../contexts/CustomThemeProvider';
-import { availableLanguages } from '../i18n';
+import { languagesInfos, LanguageTag } from '../i18n';
 
 export default function GeneralPreferences() {
   const { t } = useTranslation();
@@ -45,30 +45,30 @@
   return <SettingSwitch label={t('setting_dark_theme')} onChange={toggleMode} checked={mode === 'dark'} />;
 };
 
-const settingLanguageOptions = availableLanguages.map(({ tag, fullName }) => ({ label: fullName, value: tag }));
+const settingLanguageOptions = languagesInfos.map(({ tag, fullName }) => ({
+  label: fullName,
+  payload: tag,
+}));
 
 const SettingLanguage = () => {
   const { t, i18n } = useTranslation();
 
-  const [languageValue, setLanguageValue] = useState(i18n.language);
+  const option = useMemo(
+    // TODO: Tell Typescript the result can't be undefined
+    () => settingLanguageOptions.find((option) => option.payload === i18n.language),
+    [i18n.language]
+  );
 
-  useEffect(() => {
-    i18n.changeLanguage(languageValue);
-  }, [languageValue, i18n]);
-
-  const onChange = useCallback(
-    (event: SelectChangeEvent<unknown>) => {
-      setLanguageValue(event.target.value as string);
+  const onChange = useCallback<SettingSelectProps<LanguageTag>['onChange']>(
+    (newValue) => {
+      if (newValue) {
+        i18n.changeLanguage(newValue.payload);
+      }
     },
-    [setLanguageValue]
+    [i18n]
   );
 
   return (
-    <SettingSelect
-      label={t('setting_language')}
-      value={languageValue}
-      onChange={onChange}
-      options={settingLanguageOptions}
-    />
+    <SettingSelect label={t('setting_language')} option={option} options={settingLanguageOptions} onChange={onChange} />
   );
 };