import { useState } from 'react';
import { mapDefined, unique } from '@mary/array-fns';
import { clsx } from 'clsx';
import { usePostLanguageHistory } from '#/state/preferences/languages';
import { languageName, resolveLanguageName } from '#/locale/helpers';
import { LOCALE } from '#/locale/intl/locale';
import { type Language, LANGUAGES, LANGUAGES_MAP, langCode } from '#/locale/languages';
import * as Dialog from '#/components/Dialog';
import * as styles from '#/components/dialogs/LanguageSelectDialog.css';
import { ErrorBoundary } from '#/components/ErrorBoundary';
import { ErrorScreen } from '#/components/ErrorScreen';
import { SearchInput } from '#/components/forms/SearchInput';
import * as Toggle from '#/components/forms/Toggle';
import { Text } from '#/components/Text';
import { Button, ButtonIcon, ButtonText } from '#/components/web/Button';
import XIcon from '#/icons/central/CrossLarge_round_outlined_radius1_stroke2.svg';
import { m } from '#/paraglide/messages';
const LANGUAGE_ITEM_HEIGHT_ESTIMATE = 48;
type ListEntry =
| {
type: 'header';
label: string;
}
| {
type: 'item';
lang: Language;
};
type Props = {
handle: Dialog.DialogHandle;
titleText: string;
/** Languages checked when the dialog opens. */
currentLanguages: string[];
onSelectLanguages: (languages: string[]) => void;
maxLanguages?: number;
};
export function LanguageSelectDialog(props: Props) {
const { handle, titleText } = props;
const renderErrorBoundary = (error: unknown) => <DialogError handle={handle} details={String(error)} />;
return (
<Dialog.Root handle={handle}>
<Dialog.Popup className={styles.popup} label={titleText} scroll="body">
<ErrorBoundary renderError={renderErrorBoundary}>
<DialogInner {...props} />
</ErrorBoundary>
</Dialog.Popup>
</Dialog.Root>
);
}
// NOTE(@elijaharita): Displayed languages are split into 3 lists for
// ordering.
function mapCodeList(codeList: string[]) {
return mapDefined(codeList, (code) => LANGUAGES_MAP[code]);
}
// drop languages this engine's CLDR data can't name — they'd render as bare codes
const isNameable = (lang: Language) => resolveLanguageName(lang, LOCALE) !== undefined;
function DialogInner({ handle, titleText, currentLanguages, onSelectLanguages, maxLanguages }: Props) {
const postLanguageHistory = usePostLanguageHistory();
const [checkedCodes, setCheckedCodes] = useState(currentLanguages);
const [search, setSearch] = useState('');
const onClose = () => {
onSelectLanguages(checkedCodes);
handle.close();
};
// NOTE(@elijaharita): Get recent language codes and map them to language
// objects. Both the user account's saved language history and the current
// checked languages are displayed here.
const recentCodes = unique([...checkedCodes, ...postLanguageHistory]).slice(0, 5);
const recentLanguages = mapCodeList(recentCodes);
// NOTE(@elijaharita): helper functions
const searchLower = search.toLowerCase();
const matchesSearch = (lang: Language) =>
languageName(lang, LOCALE).toLowerCase().includes(searchLower) ||
languageName(lang, 'en').toLowerCase().includes(searchLower);
const isChecked = (lang: Language) => checkedCodes.includes(langCode(lang));
const isInRecents = (lang: Language) => recentCodes.includes(langCode(lang));
const checkedRecent = recentLanguages.filter(isChecked);
let displayedLanguages: { all: Language[]; checkedRecent: Language[]; uncheckedRecent: Language[] };
if (search) {
// NOTE(@elijaharita): if a search is active, we ALWAYS show checked
// items, as well as any items that match the search.
const uncheckedRecent = recentLanguages.filter((lang) => !isChecked(lang)).filter(matchesSearch);
const unchecked = LANGUAGES.filter((lang) => isNameable(lang) && !isChecked(lang));
const all = unchecked.filter(matchesSearch).filter((lang) => !isInRecents(lang));
displayedLanguages = {
all,
checkedRecent,
uncheckedRecent,
};
} else {
// NOTE(@elijaharita): if no search is active, we show everything.
const uncheckedRecent = recentLanguages.filter((lang) => !isChecked(lang));
const all = LANGUAGES.filter((lang) => isNameable(lang) && !isInRecents(lang));
displayedLanguages = {
all,
checkedRecent,
uncheckedRecent,
};
}
const hasRecent =
displayedLanguages.checkedRecent.length > 0 || displayedLanguages.uncheckedRecent.length > 0;
const hasAll = displayedLanguages.all.length > 0;
const listData: ListEntry[] = [
...(hasRecent ? [{ type: 'header' as const, label: m['common.status.recentlyUsed']() }] : []),
...displayedLanguages.checkedRecent.map((lang) => ({ type: 'item' as const, lang })),
...displayedLanguages.uncheckedRecent.map((lang) => ({ type: 'item' as const, lang })),
...(hasAll ? [{ type: 'header' as const, label: m['components.dialogs.language.all']() }] : []),
...displayedLanguages.all.map((lang) => ({ type: 'item' as const, lang })),
];
return (
<Toggle.Group
className={styles.group}
label={m['components.dialogs.language.selectTitle']()}
maxSelections={maxLanguages}
onChange={setCheckedCodes}
type="checkbox"
values={checkedCodes}
>
<div className={styles.header}>
<Text className={styles.title} numberOfLines={1} size="lg" weight="semiBold">
{titleText}
</Text>
<Button
className={styles.closeButton}
color="secondary"
label={m['common.a11y.closeDialog']()}
onClick={onClose}
shape="round"
size="small"
variant="ghost"
>
<ButtonIcon icon={XIcon} />
</Button>
</div>
<div className={styles.search}>
<SearchInput
autoFocus
label={m['components.dialogs.language.search']()}
maxLength={50}
onChangeText={setSearch}
onClear={() => setSearch('')}
placeholder={m['components.dialogs.language.search']()}
value={search}
/>
</div>
<Dialog.List
className={styles.list}
data={listData}
estimateHeight={LANGUAGE_ITEM_HEIGHT_ESTIMATE}
keyExtractor={(entry) => (entry.type === 'header' ? `header-${entry.label}` : langCode(entry.lang))}
ListEmptyComponent={<Empty message={m['common.list.noResults']()} />}
renderItem={({ index, item }) => {
if (item.type === 'header') {
return (
<Text
className={styles.sectionHeader({ topPadded: index !== 0 })}
color="textContrastLow"
size="md_sub"
weight="semiBold"
>
{item.label}
</Text>
);
}
const name = languageName(item.lang, LOCALE);
return (
<Toggle.Item
className={clsx(styles.item, index !== listData.length - 1 && styles.itemBorder)}
label={name}
name={langCode(item.lang)}
>
<Text className={styles.itemLabel} color="textContrastHigh" numberOfLines={1} weight="semiBold">
{name}
</Text>
<Toggle.CheckboxIndicator />
</Toggle.Item>
);
}}
/>
<Dialog.Footer>
<Button
className={styles.doneButton}
color="primary"
label={m['common.a11y.closeDialog']()}
onClick={onClose}
size="large"
>
<ButtonText>{m['common.action.done']()}</ButtonText>
</Button>
</Dialog.Footer>
</Toggle.Group>
);
}
function Empty({ message }: { message: string }) {
return (
<div className={styles.empty}>
<Text className={styles.emptyMessage} color="textContrastHigh" size="sm">
{message}
</Text>
<Text color="textContrastLow" size="xs">
(╯°□°)╯︵ ┻━┻
</Text>
</div>
);
}
function DialogError({ handle, details }: { handle: Dialog.DialogHandle; details?: string }) {
return (
<div className={styles.error}>
<ErrorScreen
title={m['common.error.ohNo']()}
message={m['common.error.unexpected']()}
details={details}
/>
<Button
label={m['common.a11y.closeDialog']()}
onClick={() => handle.close()}
color="primary"
size="large"
>
<ButtonText>{m['common.action.close']()}</ButtonText>
</Button>
</div>
);
}