From 7d3cd5ed006efca7545e929b9ef606c5ea49a67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 24 Jun 2026 16:58:24 +0200 Subject: [PATCH] feat(front): add search to the language picker (#22095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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`. Review in cubic --- .../modules/ui/input/components/Select.tsx | 24 ++++++++----- .../appearance/components/LocalePicker.tsx | 36 +++++++++++++++++-- .../twenty-ui/src/input/types/SelectOption.ts | 1 + 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/twenty-front/src/modules/ui/input/components/Select.tsx b/packages/twenty-front/src/modules/ui/input/components/Select.tsx index ed07fbc416..04d95249f6 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Select.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Select.tsx @@ -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 = ({ 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 || diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx index fa7cb26b73..9fa837ad69 100644 --- a/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/LocalePicker.tsx @@ -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 = 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 ( @@ -217,6 +246,7 @@ export const LocalePicker = () => { dropdownId="preferred-locale" dropdownWidthAuto fullWidth + withSearchInput value={currentWorkspaceMember.locale} options={localeOptions} onChange={(value) => diff --git a/packages/twenty-ui/src/input/types/SelectOption.ts b/packages/twenty-ui/src/input/types/SelectOption.ts index 21b80a01a8..d43434868c 100644 --- a/packages/twenty-ui/src/input/types/SelectOption.ts +++ b/packages/twenty-ui/src/input/types/SelectOption.ts @@ -12,4 +12,5 @@ export type SelectOption< disabled?: boolean; color?: ThemeColor | 'transparent'; contextualText?: string; + searchKeywords?: string; };