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:
@@ -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 ||
|
||||
|
||||
+33
-3
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user