feat(front): add search to the language picker (#22095)

## What

Adds a search bar to the **Settings → Experience → Language** picker,
and makes languages searchable across languages.

Each option is matched against:
- its displayed label (the name in the current UI language)
- its name **in English** — typing `chinese` finds "Chinois — Simplifié"
- its **native name** — typing `中文` finds the same option

Matching is also accent-insensitive (`francais` finds "Français").

## How

- The shared `Select` already supports search via `withSearchInput`
(used by the currency/country pickers) — the picker just opts in.
- Cross-language matching uses the platform `Intl.DisplayNames` API to
derive each language's English and native names — no hardcoded
translation tables, no extra requests.
- A generic optional `searchKeywords` field on `SelectOption` lets the
`Select` filter match synonyms on top of the label; the filter now runs
through the existing `normalizeSearchText`, hence the
accent-insensitivity. Behavior is unchanged for every existing `Select`
(strict superset for ASCII labels).

## Test

- `nx typecheck` / `nx lint` pass for `twenty-front` and `twenty-ui`.
- Open the Language dropdown and try `chinese`, `中文`, or `francais`.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22095?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-24 16:58:24 +02:00
committed by GitHub
parent dd9ad876a4
commit 7d3cd5ed00
3 changed files with 49 additions and 12 deletions
@@ -23,6 +23,7 @@ import { type IconComponent } from 'twenty-ui/icon';
import { type SelectOption } from 'twenty-ui/input';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
export type SelectSizeVariant = 'small' | 'default';
@@ -124,15 +125,20 @@ export const Select = <Value extends SelectValue>({
return null;
}, [emptyOption, options, pinnedOption, value]);
const filteredOptions = useMemo(
() =>
searchInputValue
? options.filter(({ label }) =>
label.toLowerCase().includes(searchInputValue.toLowerCase()),
)
: options,
[options, searchInputValue],
);
const filteredOptions = useMemo(() => {
if (!isNonEmptyString(searchInputValue)) {
return options;
}
const normalizedSearch = normalizeSearchText(searchInputValue);
return options.filter(
({ label, searchKeywords }) =>
normalizeSearchText(label).includes(normalizedSearch) ||
(isDefined(searchKeywords) &&
normalizeSearchText(searchKeywords).includes(normalizedSearch)),
);
}, [options, searchInputValue]);
const isDisabled =
disabledFromProps ||
@@ -10,6 +10,7 @@ import { useInvalidateMetadataStore } from '@/metadata-store/hooks/useInvalidate
import { useStore } from 'jotai';
import { useLingui } from '@lingui/react/macro';
import { enUS } from 'date-fns/locale';
import { isNonEmptyString } from '@sniptt/guards';
import { APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
@@ -17,6 +18,31 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { logError } from '~/utils/logError';
// Language name in English and in its own language, so it is searchable
// regardless of the UI language — e.g. typing "chinese" or "中文".
const getLocaleSearchKeywords = (
locale: (typeof APP_LOCALES)[keyof typeof APP_LOCALES],
): string => {
const displayNames = [locale, APP_LOCALES.en].map((displayLocale) => {
try {
return new Intl.DisplayNames([displayLocale], { type: 'language' }).of(
locale,
);
} catch {
return undefined;
}
});
return [...new Set(displayNames.filter(isNonEmptyString))].join(' ');
};
const LOCALE_SEARCH_KEYWORDS: Record<string, string> = Object.fromEntries(
Object.values(APP_LOCALES).map((locale) => [
locale,
getLocaleSearchKeywords(locale),
]),
);
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
@@ -207,9 +233,12 @@ export const LocalePicker = () => {
});
}
const localeOptions = [...unsortedLocaleOptions].sort((a, b) =>
a.label.localeCompare(b.label),
);
const localeOptions = [...unsortedLocaleOptions]
.sort((a, b) => a.label.localeCompare(b.label))
.map((option) => ({
...option,
searchKeywords: LOCALE_SEARCH_KEYWORDS[option.value],
}));
return (
<StyledContainer>
@@ -217,6 +246,7 @@ export const LocalePicker = () => {
dropdownId="preferred-locale"
dropdownWidthAuto
fullWidth
withSearchInput
value={currentWorkspaceMember.locale}
options={localeOptions}
onChange={(value) =>
@@ -12,4 +12,5 @@ export type SelectOption<
disabled?: boolean;
color?: ThemeColor | 'transparent';
contextualText?: string;
searchKeywords?: string;
};