Skip to content

Commit 1b9c99a

Browse files
andrew-polkclaude
andcommitted
feat(language-chooser-controller): add fuzzy script search and variant format warning
Brings the controller to feature parity with the react-mui UI ahead of switching react to use the common controller: - searchScriptOptions() returns all scripts for an empty query, otherwise fuzzy matches on script name and code (BL-14903) - warnInvalidVariant callback field fires when a submitted variant (or unlisted language name) is not a valid BCP 47 variant subtag; submission still proceeds, matching the react-mui warn-and-submit behavior (BL-14904) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e480fa6 commit 1b9c99a

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

components/language-chooser/common/language-chooser-controller/src/view-models/language-chooser.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
createTagFromOrthography,
44
defaultDisplayName,
55
formatDialectCode,
6+
fuzzilySearchForScripts,
7+
getAllScripts,
68
type ICustomizableLanguageDetails,
79
type ILanguage,
810
type IOrthography,
@@ -11,6 +13,7 @@ import {
1113
isManuallyEnteredTagLanguage,
1214
isUnlistedLanguage,
1315
isValidBcp47Tag,
16+
isValidBcp47VariantSubtag,
1417
languageForManuallyEnteredTag,
1518
UNLISTED_LANGUAGE,
1619
} from "@ethnolib/find-language";
@@ -61,6 +64,13 @@ export function useLanguageChooserViewModel(
6164
// eslint-disable-next-line @typescript-eslint/no-empty-function
6265
() => {}
6366
);
67+
// Called when a submitted variant (dialect) is not in valid BCP 47 variant subtag format.
68+
// Submission still proceeds; the resulting tag stays valid because dialects become
69+
// private use (-x-) subtags. Views typically show a warning (BL-14904).
70+
const warnInvalidVariant = new Field<(dialect: string) => void>(
71+
// eslint-disable-next-line @typescript-eslint/no-empty-function
72+
() => {}
73+
);
6474

6575
const searchString = new Field("", () => {
6676
_onSearchStringUpdated();
@@ -247,13 +257,28 @@ export function useLanguageChooserViewModel(
247257
}
248258
}
249259

260+
// All the scripts if the query is empty, otherwise fuzzy matches on script name
261+
// and code, best matches first. For script dropdowns with search (BL-14903).
262+
function searchScriptOptions(query: string): IScript[] {
263+
return query
264+
? fuzzilySearchForScripts(getAllScripts(), query)
265+
: getAllScripts();
266+
}
267+
268+
function _warnIfInvalidVariant(dialect: string | undefined) {
269+
if (dialect && !isValidBcp47VariantSubtag(dialect)) {
270+
warnInvalidVariant.value(dialect);
271+
}
272+
}
273+
250274
function submitUnlistedLanguageModal({
251275
name,
252276
region,
253277
}: {
254278
name: string;
255279
region: IRegion;
256280
}) {
281+
_warnIfInvalidVariant(name);
257282
const normalizedDialect = formatDialectCode(name);
258283
customizations.requestUpdate({
259284
customDisplayName: name,
@@ -271,6 +296,7 @@ export function useLanguageChooserViewModel(
271296
region?: IRegion;
272297
dialect?: string;
273298
}) {
299+
_warnIfInvalidVariant(dialect);
274300
selectedScript.requestUpdate(script);
275301
customizations.requestUpdate({
276302
region,
@@ -299,12 +325,14 @@ export function useLanguageChooserViewModel(
299325
showUnlistedLanguageModal,
300326
showCustomizeLanguageModal,
301327
promptForCustomTag,
328+
warnInvalidVariant,
302329

303330
// Methods
304331
search,
305332
onCustomizeButtonClicked,
306333
submitUnlistedLanguageModal,
307334
submitCustomizeLanguageModal,
335+
searchScriptOptions,
308336
};
309337
}
310338

components/language-chooser/common/language-chooser-controller/test/language-chooser.spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,96 @@ describe("customize language modal", () => {
730730
});
731731
});
732732

733+
describe("script options search", () => {
734+
it("returns all scripts for an empty query", () => {
735+
const t = new TestHelper();
736+
const allScripts = t.viewModel.searchScriptOptions("");
737+
expect(allScripts.length).toBeGreaterThan(100);
738+
});
739+
740+
it("finds a script by fuzzy matching a misspelled name", () => {
741+
const t = new TestHelper();
742+
const results = t.viewModel.searchScriptOptions("Cyrilic");
743+
expect(results[0].code).toBe("Cyrl");
744+
});
745+
746+
it("finds a script by code", () => {
747+
const t = new TestHelper();
748+
const results = t.viewModel.searchScriptOptions("Latn");
749+
expect(results[0].code).toBe("Latn");
750+
});
751+
});
752+
753+
describe("invalid variant warning", () => {
754+
it("fires when customize modal submits an invalidly formatted variant", () => {
755+
const t = new TestHelper({ initialLanguages: [NorthernUzbekLanguage] });
756+
t.viewModel.listedLanguages.value[0].isSelected.requestUpdate(true);
757+
const spy = vi.fn();
758+
t.viewModel.warnInvalidVariant.requestUpdate(spy);
759+
760+
// 11 characters, too long for a BCP 47 variant subtag (5-8)
761+
t.viewModel.submitCustomizeLanguageModal({ dialect: "dialectTest" });
762+
763+
expect(spy).toHaveBeenCalledWith("dialectTest");
764+
});
765+
766+
it("still applies the customizations after warning", () => {
767+
const t = new TestHelper({ initialLanguages: [NorthernUzbekLanguage] });
768+
t.viewModel.listedLanguages.value[0].isSelected.requestUpdate(true);
769+
t.viewModel.warnInvalidVariant.requestUpdate(vi.fn());
770+
771+
t.viewModel.submitCustomizeLanguageModal({ dialect: "dialectTest" });
772+
773+
expect(t.viewModel.customizations.value?.dialect).toBe("dialectTest");
774+
});
775+
776+
it("does not fire for a valid variant", () => {
777+
const t = new TestHelper({ initialLanguages: [NorthernUzbekLanguage] });
778+
t.viewModel.listedLanguages.value[0].isSelected.requestUpdate(true);
779+
const spy = vi.fn();
780+
t.viewModel.warnInvalidVariant.requestUpdate(spy);
781+
782+
t.viewModel.submitCustomizeLanguageModal({ dialect: "foobar" });
783+
784+
expect(spy).not.toHaveBeenCalled();
785+
});
786+
787+
it("does not fire for an empty variant", () => {
788+
const t = new TestHelper({ initialLanguages: [NorthernUzbekLanguage] });
789+
t.viewModel.listedLanguages.value[0].isSelected.requestUpdate(true);
790+
const spy = vi.fn();
791+
t.viewModel.warnInvalidVariant.requestUpdate(spy);
792+
793+
t.viewModel.submitCustomizeLanguageModal({});
794+
795+
expect(spy).not.toHaveBeenCalled();
796+
});
797+
798+
it("does not fire for ai- variants", () => {
799+
const t = new TestHelper({ initialLanguages: [NorthernUzbekLanguage] });
800+
t.viewModel.listedLanguages.value[0].isSelected.requestUpdate(true);
801+
const spy = vi.fn();
802+
t.viewModel.warnInvalidVariant.requestUpdate(spy);
803+
804+
t.viewModel.submitCustomizeLanguageModal({ dialect: "ai-translation" });
805+
806+
expect(spy).not.toHaveBeenCalled();
807+
});
808+
809+
it("fires when unlisted modal submits a name that is not a valid variant", () => {
810+
const t = new TestHelper();
811+
const spy = vi.fn();
812+
t.viewModel.warnInvalidVariant.requestUpdate(spy);
813+
814+
t.viewModel.submitUnlistedLanguageModal({
815+
name: "Foo Bar",
816+
region: AndorraRegion,
817+
});
818+
819+
expect(spy).toHaveBeenCalledWith("Foo Bar");
820+
});
821+
});
822+
733823
describe("edit custom tag prompt", () => {
734824
it("shows when custom tag exists and customization button is clicked", () => {
735825
const t = new TestHelper();

0 commit comments

Comments
 (0)