Something went wrong. Try again.
personal fork of bluesky app bsky.kelinci.net
Something went wrong. Try again.
8.0 kB · 257 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258import { 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 codesconst 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> );}