Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions components/language-chooser/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Tests (Unit tests and E2E tests)

- If you can't figure out why a test is failing, stop and inform the programmer. Never change the objectives nor expected outcomes of a test unless explicitly instructed to.
- There are several special case languages, handled in searchResultModifiers.ts and/or called out in the Anomalies and Special Situations section of macrolanguageNotes.md. Be sure not to use any of these languages in general-case tests. Whenever adding tests, also test these special cases if appropriate.
- Avoid running the same exact scenario multiple times. If one test scenario is useful for testing multiple behaviors/outcomes/properties, just run it once and list all of those behaviors in the description.
- Be careful to prevent falsely passing tests. Do sanity checks.
- In e2e tests, be careful to avoid adding tests for behavior which is already covered by tests in a different file.
- You can trust that libraries behave the way they are supposed to; don't write tests for behavior which is entirely handled by a library. E.g. no need to test that MUI components are behaving as specified.
- When adding tests, make sure they are in a test suite where they belong
- When tests involve language searching, search results may come back in batches with delays in between, and lazyload. So the immediate absence of a certain result is not enough to verify the absence of that result. And when checking for a result or checking the count of results, do something that will wait for the results to appear.
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import { describe, expect, it } from "vitest";
import { expectTypeOf } from "vitest";
import { codeMatches } from "./languageTagUtils";
import { stripDemarcation } from "./matchingSubstringDemarcation";
import {
defaultSearchResultModifier,
LanguageSearcher,
} from "@ethnolib/find-language";
import { defaultSearchResultModifier } from "./searchResultModifiers";
import { LanguageSearcher } from "./languageSearcher";

// wait for all the results from asyncSearchForLanguage so we can check them
export async function asyncGetAllLanguageResults(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Install with npm: `npm i @ethnolib/language-chooser-react-hook`

- `saveLanguageDetails: (details: ICustomizableLanguageDetails, script: IScript | undefined) => void` - Sets `customizableLanguageDetails` and `selectedScript`.

- `resetTo: (searchString: string, selectionLanguageTag?: string, initialCustomDisplayName?: string) => void` - For restoring preexisting data when the LanguageChooser is first opened. Sets `selectedLanguage`, `selectedScript`, and `customizableLanguageDetails` to the values in `initialState`.
- `resetTo: (searchString?: string, selectionLanguageTag?: string, initialCustomDisplayName?: string) => void` - For restoring preexisting data when the LanguageChooser is first opened. Sets `selectedLanguage`, `selectedScript`, and `customizableLanguageDetails` to the values in `initialState`.

### Example

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface ILanguageChooser {
script: IScript | undefined
) => void;
resetTo: (
searchString: string,
searchString?: string,
selectionLanguageTag?: string,
initialCustomDisplayName?: string
) => void;
Expand Down Expand Up @@ -114,17 +114,16 @@ export const useLanguageChooser = (
})();
}, [searchString]);

// For reopening to a specific selection. We should then also set the search string
// such that the selected language is visible.
// For reopening to a specific selection
function resetTo(
searchString: string,
// the language in selectionLanguageTag must be a result of this search string or selection won't display
// unless it is a manually entered tag, in which case there is never a search result anyway
searchString?: string,
selectionLanguageTag?: string,
initialCustomDisplayName?: string // all info can be captured in language tag except display name
) {
onSearchStringChange(searchString);
if (!selectionLanguageTag) return;
if (!selectionLanguageTag) {
onSearchStringChange(searchString || "");
return;
}

let initialSelections = parseLangtagFromLangChooser(
selectionLanguageTag || "",
Expand All @@ -140,6 +139,11 @@ export const useLanguageChooser = (
},
};
}
// If we have an initially selected language but no search string, might as well set the search string to something
// that will definitely show that selected language in the results
searchString = searchString || initialSelections?.language?.languageSubtag;
onSearchStringChange(searchString || "");

if (initialSelections?.language) {
selectLanguage(initialSelections?.language as ILanguage);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,12 @@ The Language Chooser will adopt the primary color of the [MUI theme](https://mui
results: FuseResult<ILanguage>[],
searchString: string
) => ILanguage[]` - Can be used to add, remove, and modify results. See [find-language](../../common/find-language/README.md) for details.
- `initialSearchString?: string`
- `initialSearchString?: string` **Currently (February 2026) the initialSearchString will not be displayed in the search bar, but the results shown will reflect it.**
- `initialSelectionLanguageTag?: string` - The Language Chooser will open with the language information captured by this language tag being already selected. If the user has already previously selected a language and is using the LanguageChooser to modify their selection, use this to prepopulate with their preexisting selection.

- We expect this to be a language tag which was output either by this Language Chooser or by the [libPalasso](https://github.com/sillsdev/libpalaso)-based language picker. **The language subtag must be the default language subtag for the language** (the first part of the "tag" field of langtags.json), which may be a 2-letter code even if an equivalent ISO 639-3 code exists. May not corectly handle irregular codes, extension codes, langtags with both macrolanguage code and language code, and other comparably unusual nuances of BCP-47.

- If the initialSelectionLanguageTag does not have an explicit script subtag, the Language Chooser will select the script implied by the language subtag and region subtag if present. For example, if initialSelectionLanguageTag is "uz" (Uzbek), Latin script will be selected because "uz" is an equivalent language tag to "uz-Latn". If initialSelectionLanguageTag is "uz-AF" (Uzbek, Afghanistan), Arabic script will be selected because "uz-AF" is an equivalent language tag to "uz-Arab-AF".

- **If an initialSelectionLanguageTag is provided, an initialSearchString must also be provided such that the initially selected language is a result of the search string in order for the selected card to be visible.**

- `initialCustomDisplayName?: string` - If using initialSelectionLanguageTag to prepopulate with a language, this field can be used to prepopulate a customized display name for the language.
- `onSelectionChange?: (orthographyInfo: IOrthography | undefined, languageTag: string | undefined) => void` - Whenever the user makes or unselects a complete language selection, this function will be called with the selected language information or undefined, respectively.
- `rightPanelComponent?: React.ReactNode` - A slot for a component to go in the space on the upper right side of the language chooser. See the Storybook Dialog Demo -> Additional Right Panel Component for an example.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
findChechenCyrlCard,
findChechenCard,
scriptCardTestId,
search,
selectChechenCard,
} from "./e2eHelpers";

let page: Page; // All the tests in this file use the same page object to save time; we only load the language chooser once.

test.describe("Selection toggle script card behavior", () => {
test.describe("Selection toggle card behavior", () => {
test.beforeAll(async ({ browser }) => {
page = await createPageAndLoadLanguageChooser(browser);
});
Expand Down Expand Up @@ -54,4 +56,82 @@ test.describe("Selection toggle script card behavior", () => {
await page.locator("#search-bar").fill("uzbek ");
await expect(cyrlCard).not.toBeVisible();
});

test("Rapid clicking doesn't break selection state", async () => {
await page.locator("#search-bar").fill("chechen");

const chechenCard = page.getByTestId("language-card-che");

// Click rapidly multiple times
await chechenCard.click();
await chechenCard.click();
await chechenCard.click();
await chechenCard.click();

// Wait a moment for state to settle
await page.waitForTimeout(300);

// Card should be in a consistent state (unselected after even number of clicks)
const cardButton = page.locator(
`button:has([data-testid="language-card-che"])`
);

// Should be unselected (4 clicks = toggled 4 times)
await expect(cardButton).not.toHaveClass(/.*selected-option-card-button.*/);
});

test("Toggling script cards multiple times maintains consistency", async () => {
await selectChechenCard(page);

const cyrlCard = page.getByTestId("script-card-Cyrl");
const cardButton = page.locator(
`button:has([data-testid="script-card-Cyrl"])`
);

// Toggle on
await cyrlCard.click();
await expect(cardButton).toHaveClass(/.*selected-option-card-button.*/);

// Toggle off
await cyrlCard.click();
await expect(cardButton).not.toHaveClass(/.*selected-option-card-button.*/);

// Toggle on again
await cyrlCard.click();
await expect(cardButton).toHaveClass(/.*selected-option-card-button.*/);

// Toggle off again
await cyrlCard.click();
await expect(cardButton).not.toHaveClass(/.*selected-option-card-button.*/);

// Final state should be consistent
const tagPreview = page.getByTestId("right-panel-langtag-preview");
await expect(tagPreview).toContainText("ce");
await expect(tagPreview).not.toContainText("ce-Cyrl");
});

test("Selected language card scrolls to top when clicked", async () => {
await search(page, "zebra");
const cards = page.locator(".option-card-button");
const thirdCard = cards.nth(2);
const initialThirdCardPosition = await thirdCard.evaluate(
(el) => el.getBoundingClientRect().top
);

// without any scrolling, third card should be visible but lower down without scrolling
await expect(thirdCard).toBeVisible();
// click on third card, which should scroll it to top
await thirdCard.click();
// Wait a bit for smooth scroll
await page.waitForTimeout(500);

const newThirdCardPosition = await thirdCard.evaluate(
(el) => el.getBoundingClientRect().top
);

// the third card should have moved up by more than 100px
await expect(newThirdCardPosition).toBeLessThan(
initialThirdCardPosition - 100
);
});
});
Loading