From 2e3a465229b1f2c4fde4f0dbc595319a00bddb83 Mon Sep 17 00:00:00 2001 From: Andrew Polk Date: Thu, 12 Mar 2026 16:43:46 -0700 Subject: [PATCH 1/2] feat: share language chooser behavior across adapters Move language chooser behavior into the shared controller and make the React hook a thin state-management-react adapter. Svelte/common behavior changes: - align shared search, selection, script, and customization flows with the pre-existing React semantics - preserve custom display name while clearing region and dialect when the selected script changes - restore manually entered tags through resetTo in the shared controller - keep each in-flight search bound to the searchResultModifier that was active when the search started - tighten unlisted-language validation so submit only becomes ready when the dialect has alphanumeric content and the region name is non-whitespace React behavior changes: - preserve the existing public hook API while sourcing behavior from the shared controller - keep existing chooser behavior for normal flows, including immediate search reset semantics and selection callbacks - fix the same unlisted-language validation bug for whitespace and punctuation-only values - keep searchResultModifier behavior stable across async search batches Validation: - full workspace test suite passes after rebase onto state-management-react --- .../src/view-models/language-chooser.ts | 333 +++++++++++++++--- .../test/language-chooser.spec.ts | 75 ++++ .../language-chooser-react-hook/package.json | 11 +- .../useLanguageChooser.spec.ts | 148 +++++++- .../useLanguageChooser.ts | 291 +++------------ .../vitest.config.ts | 1 + .../state-management-react/package.json | 5 +- .../src/use-field.spec.ts | 19 - .../state-management-react/src/use-field.ts | 6 +- .../state-management-react/vite.config.ts | 3 - package-lock.json | 1 + 11 files changed, 572 insertions(+), 321 deletions(-) diff --git a/components/language-chooser/common/language-chooser-controller/src/view-models/language-chooser.ts b/components/language-chooser/common/language-chooser-controller/src/view-models/language-chooser.ts index f1014d21..219b06ed 100644 --- a/components/language-chooser/common/language-chooser-controller/src/view-models/language-chooser.ts +++ b/components/language-chooser/common/language-chooser-controller/src/view-models/language-chooser.ts @@ -1,6 +1,8 @@ import { asyncSearchForLanguage, + codeMatches, createTagFromOrthography, + deepStripDemarcation, defaultDisplayName, formatDialectCode, type ICustomizableLanguageDetails, @@ -12,6 +14,7 @@ import { isUnlistedLanguage, isValidBcp47Tag, languageForManuallyEnteredTag, + parseLangtagFromLangChooser, UNLISTED_LANGUAGE, } from "@ethnolib/find-language"; import { Field } from "@ethnolib/state-management-core"; @@ -20,10 +23,17 @@ import { useLanguageCardViewModel, } from "./language-card"; import { ScriptCardViewModel, useScriptCardViewModel } from "./script-card"; -import { selectItem } from "../selectable"; interface UseLanguageChooserParams { initialLanguages?: ILanguage[]; + onSelectionChange?: ( + orthography: IOrthography | undefined, + langtag: string | undefined + ) => void; + searchResultModifier?: ( + results: ILanguage[], + searchString: string + ) => ILanguage[]; } export type LanguageChooserViewModel = ReturnType< @@ -34,7 +44,11 @@ export function useLanguageChooserViewModel( params: UseLanguageChooserParams = {} ) { const { initialLanguages } = params; + let selectionChangeListener = params.onSelectionChange; + let searchResultModifier = params.searchResultModifier; + let previousStateWasValidSelection = false; + const languageResults = new Field([]); const listedLanguages = new Field([]); const listedScripts = new Field([]); const tagPreview = new Field(""); @@ -80,38 +94,72 @@ export function useLanguageChooserViewModel( }); let _currentSearchId = 0; + let rawLanguageResults: ILanguage[] = []; function _onSearchStringUpdated() { search(searchString.value); } - function _appendLanguages(languages: ILanguage[]) { - const baseIndex = listedLanguages.value.length; - const newLanguages = languages.map((lang, i) => - useLanguageCardViewModel(lang, { - onSelect: (isSelected) => - isSelected - ? _onLanguageSelected(baseIndex + i) - : _onLanguageDeselected(), - }) - ); + function _clearCustomizations() { + customizations.value = undefined; + } - listedLanguages.value = [...listedLanguages.value, ...newLanguages]; + function _clearCustomizationsExceptDisplayName() { + customizations.value = { + customDisplayName: customizations.value?.customDisplayName, + }; } - function _onLanguageSelected(index: number) { - selectItem(index, listedLanguages.value); - selectedLanguage.value = listedLanguages.value[index].language; - selectedScript.value = undefined; - _updateScriptList(selectedLanguage.value); - customizations.value = undefined; - _onOrthographyChanged(); + function _syncLanguageCardSelection() { + listedLanguages.value.forEach((languageCard) => { + languageCard.isSelected.value = codeMatches( + languageCard.language.iso639_3_code, + selectedLanguage.value?.iso639_3_code + ); + }); } - function _onLanguageDeselected() { - selectedLanguage.value = undefined; - selectedScript.value = undefined; - _onOrthographyChanged(); + function _syncScriptSelection() { + listedScripts.value.forEach((scriptCard) => { + scriptCard.isSelected.value = codeMatches( + scriptCard.script.code, + selectedScript.value?.code + ); + }); + } + + function _createLanguageCardViewModel(language: ILanguage) { + const languageCard = useLanguageCardViewModel(language, { + onSelect: (isSelected) => { + if (isSelected) { + selectLanguage(language); + } else if ( + codeMatches( + language.iso639_3_code, + selectedLanguage.value?.iso639_3_code + ) + ) { + clearLanguageSelection(); + } + }, + }); + languageCard.isSelected.value = codeMatches( + language.iso639_3_code, + selectedLanguage.value?.iso639_3_code + ); + return languageCard; + } + + function _appendLanguages( + languages: ILanguage[], + modifier: (results: ILanguage[], searchString: string) => ILanguage[], + searchQuery: string + ) { + rawLanguageResults = [...rawLanguageResults, ...languages]; + const modifiedLanguages = modifier(rawLanguageResults, searchQuery); + languageResults.value = modifiedLanguages; + listedLanguages.value = modifiedLanguages.map(_createLanguageCardViewModel); + _syncLanguageCardSelection(); } function _updateScriptList(selectedLang: ILanguage) { @@ -128,20 +176,14 @@ export function useLanguageChooserViewModel( listedScripts.value = scripts.map((script, i) => useScriptCardViewModel(script, { onSelect: (isSelected) => - isSelected ? _onScriptSelected(i) : _onScriptDeselected(), + isSelected ? _onScriptSelected(i) : clearScriptSelection(), }) ); + _syncScriptSelection(); } function _onScriptSelected(index: number) { - selectItem(index, listedScripts.value); - selectedScript.value = listedScripts.value[index].script; - _onOrthographyChanged(); - } - - function _onScriptDeselected() { - selectedScript.value = undefined; - _onOrthographyChanged(); + selectScript(listedScripts.value[index].script); } function _onDisplayNameChanged() { @@ -149,7 +191,6 @@ export function useLanguageChooserViewModel( ...(customizations.value ?? {}), customDisplayName: displayName.value, }; - _updateIsReadyToSubmit(); } @@ -161,13 +202,16 @@ export function useLanguageChooserViewModel( function _onCustomLanguageTagChanged() { searchString.value = ""; _cancelSearch(); + rawLanguageResults = []; + languageResults.value = []; listedLanguages.value = []; + listedScripts.value = []; selectedLanguage.value = languageForManuallyEnteredTag( customLanguageTag.value ); selectedScript.value = undefined; tagPreview.value = customLanguageTag.value; - customizations.value = undefined; + _clearCustomizations(); _onOrthographyChanged(); } @@ -175,6 +219,7 @@ export function useLanguageChooserViewModel( _updateTagPreview(); _updateDisplayName(); _updateIsReadyToSubmit(); + _notifySelectionChange(); } function _updateTagPreview() { @@ -206,27 +251,198 @@ export function useLanguageChooserViewModel( _currentSearchId++; } + function _notifySelectionChange() { + if (!selectionChangeListener) { + return; + } + + if (isReadyToSubmit.value) { + const resultingOrthography = deepStripDemarcation({ + language: selectedLanguage.value, + script: selectedScript.value, + customDetails: customizations.value, + }) as IOrthography; + selectionChangeListener( + resultingOrthography, + createTagFromOrthography(resultingOrthography) + ); + previousStateWasValidSelection = true; + } else if (previousStateWasValidSelection) { + selectionChangeListener(undefined, undefined); + previousStateWasValidSelection = false; + } + } + // Public methods async function search(query: string) { searchString.value = query; - _onLanguageDeselected(); + selectedLanguage.value = undefined; + selectedScript.value = undefined; + listedScripts.value = []; + _syncLanguageCardSelection(); + _syncScriptSelection(); customLanguageTag.value = ""; - customizations.value = undefined; + _clearCustomizations(); _updateTagPreview(); + rawLanguageResults = []; + languageResults.value = []; listedLanguages.value = []; _cancelSearch(); + _onOrthographyChanged(); if (query.length > 1) { const searchId = _currentSearchId; + const modifierForThisSearch = + searchResultModifier || ((results) => results); await asyncSearchForLanguage(query, (results) => { if (searchId !== _currentSearchId) { return false; } - _appendLanguages(results); + _appendLanguages(results, modifierForThisSearch, query); return true; }); } } + function onSearchStringChange(query: string) { + searchString.requestUpdate(query); + } + + function selectLanguage(language: ILanguage) { + selectedLanguage.value = language; + selectedScript.value = + language.scripts.length === 1 ? language.scripts[0] : undefined; + _updateScriptList(language); + _clearCustomizations(); + _syncLanguageCardSelection(); + _syncScriptSelection(); + _onOrthographyChanged(); + } + + function selectUnlistedLanguage() { + selectLanguage(UNLISTED_LANGUAGE); + } + + function selectManuallyEnteredTagLanguage(manuallyEnteredTag: string) { + searchString.value = ""; + rawLanguageResults = []; + languageResults.value = []; + listedLanguages.value = []; + listedScripts.value = []; + selectedLanguage.value = languageForManuallyEnteredTag(manuallyEnteredTag); + selectedScript.value = undefined; + customLanguageTag.value = manuallyEnteredTag; + _clearCustomizations(); + _syncLanguageCardSelection(); + _syncScriptSelection(); + _onOrthographyChanged(); + } + + function clearLanguageSelection() { + selectedLanguage.value = undefined; + selectedScript.value = undefined; + listedScripts.value = []; + _clearCustomizations(); + _syncLanguageCardSelection(); + _syncScriptSelection(); + _onOrthographyChanged(); + } + + function selectScript(script: IScript) { + selectedScript.value = script; + _clearCustomizationsExceptDisplayName(); + _syncScriptSelection(); + _onOrthographyChanged(); + } + + function clearScriptSelection() { + selectedScript.value = undefined; + _clearCustomizationsExceptDisplayName(); + _syncScriptSelection(); + _onOrthographyChanged(); + } + + function saveLanguageDetails( + details: ICustomizableLanguageDetails, + script: IScript | undefined + ) { + customizations.value = details; + if (!script && selectedLanguage.value?.scripts.length === 1) { + script = selectedLanguage.value.scripts[0]; + } + selectedScript.value = script; + _syncScriptSelection(); + _onOrthographyChanged(); + } + + function resetTo( + initialSearchString?: string, + selectionLanguageTag?: string, + initialCustomDisplayName?: string + ) { + if (!selectionLanguageTag) { + onSearchStringChange(initialSearchString || ""); + return; + } + + let initialSelections = parseLangtagFromLangChooser( + selectionLanguageTag || "", + searchResultModifier + ); + if (selectionLanguageTag && !initialSelections) { + initialSelections = { + language: languageForManuallyEnteredTag(selectionLanguageTag || ""), + script: undefined, + customDetails: { + customDisplayName: initialCustomDisplayName, + }, + }; + } + + initialSearchString = + initialSearchString || initialSelections?.language?.languageSubtag; + onSearchStringChange(initialSearchString || ""); + + if (initialSelections?.language) { + selectLanguage(initialSelections.language as ILanguage); + } + if (initialSelections?.script) { + selectScript(initialSelections.script); + } + + customizations.value = { + ...(initialSelections?.customDetails || {}), + customDisplayName: + initialCustomDisplayName && + initialCustomDisplayName !== + defaultDisplayName( + initialSelections?.language, + initialSelections?.script + ) + ? initialCustomDisplayName + : undefined, + }; + _onOrthographyChanged(); + } + + function setSelectionChangeListener( + callback: + | (( + orthography: IOrthography | undefined, + langtag: string | undefined + ) => void) + | undefined + ) { + selectionChangeListener = callback; + } + + function setSearchResultModifier( + modifier: + | ((results: ILanguage[], searchString: string) => ILanguage[]) + | undefined + ) { + searchResultModifier = modifier; + } + function onCustomizeButtonClicked() { if (customLanguageTag.value) { promptForCustomTag.value(customLanguageTag.value); @@ -255,11 +471,13 @@ export function useLanguageChooserViewModel( region: IRegion; }) { const normalizedDialect = formatDialectCode(name); - customizations.requestUpdate({ + selectUnlistedLanguage(); + customizations.value = { customDisplayName: name, dialect: normalizedDialect, region, - }); + }; + _onOrthographyChanged(); } function submitCustomizeLanguageModal({ @@ -271,21 +489,28 @@ export function useLanguageChooserViewModel( region?: IRegion; dialect?: string; }) { - selectedScript.requestUpdate(script); - customizations.requestUpdate({ - region, - dialect, - customDisplayName: customizations.value?.customDisplayName, - }); + saveLanguageDetails( + { + region, + dialect, + customDisplayName: customizations.value?.customDisplayName, + }, + script + ); } if (initialLanguages) { - _appendLanguages(initialLanguages); + _appendLanguages( + initialLanguages, + searchResultModifier || ((results) => results), + "" + ); } _updateTagPreview(); return { // Fields + languageResults, listedLanguages, listedScripts, searchString, @@ -293,8 +518,10 @@ export function useLanguageChooserViewModel( displayName, selectedLanguage, selectedScript, + customizableLanguageDetails: customizations, customizations, customLanguageTag, + readyToSubmit: isReadyToSubmit, isReadyToSubmit, showUnlistedLanguageModal, showCustomizeLanguageModal, @@ -302,6 +529,17 @@ export function useLanguageChooserViewModel( // Methods search, + onSearchStringChange, + selectLanguage, + selectUnlistedLanguage, + selectManuallyEnteredTagLanguage, + clearLanguageSelection, + selectScript, + clearScriptSelection, + saveLanguageDetails, + resetTo, + setSelectionChangeListener, + setSearchResultModifier, onCustomizeButtonClicked, submitUnlistedLanguageModal, submitCustomizeLanguageModal, @@ -330,6 +568,7 @@ function hasValidDisplayName(selection: IOrthography) { export function canSubmitOrthography(selection: IOrthography): boolean { const normalizedDialect = formatDialectCode(selection.customDetails?.dialect); + const hasDialectCode = /[a-z0-9]/i.test(normalizedDialect); const hasRegionName = !!selection.customDetails?.region?.name?.trim(); return ( !!selection.language && @@ -338,7 +577,7 @@ export function canSubmitOrthography(selection: IOrthography): boolean { (!!selection.script || selection.language?.scripts?.length === 0) && // if unlisted language, name and country are required (!isUnlistedLanguage(selection.language) || - (!!normalizedDialect && hasRegionName)) && + (hasDialectCode && hasRegionName)) && // if this was a manually entered langtag, check that tag is valid BCP 47 (!isManuallyEnteredTagLanguage(selection.language) || isValidBcp47Tag(selection.language?.manuallyEnteredTag)) diff --git a/components/language-chooser/common/language-chooser-controller/test/language-chooser.spec.ts b/components/language-chooser/common/language-chooser-controller/test/language-chooser.spec.ts index e2a83d66..0e32f172 100644 --- a/components/language-chooser/common/language-chooser-controller/test/language-chooser.spec.ts +++ b/components/language-chooser/common/language-chooser-controller/test/language-chooser.spec.ts @@ -308,6 +308,19 @@ describe("search", () => { await Promise.all([search1, search2, search3]); expect(test.viewModel.listedLanguages.value.length).toBe(1); }); + + it("applies the active search result modifier for the duration of a search", async () => { + const test = new TestHelper(); + + test.viewModel.setSearchResultModifier((results) => results.slice(0, 1)); + const searchPromise = test.viewModel.search("arabic"); + test.viewModel.setSearchResultModifier((results) => results.slice(0, 3)); + + await searchPromise; + + expect(test.viewModel.languageResults.value.length).toBe(1); + expect(test.viewModel.listedLanguages.value.length).toBe(1); + }); }); describe("selected language", () => { @@ -370,6 +383,26 @@ describe("selected script", () => { WaataLanguage.scripts[0] ); }); + + it("clears region and dialect but preserves display name when script changes", () => { + const test = new TestHelper({ initialLanguages: [NorthernUzbekLanguage] }); + + test.viewModel.selectLanguage(NorthernUzbekLanguage); + test.viewModel.saveLanguageDetails( + { + customDisplayName: "Custom Uzbek", + region: AndorraRegion, + dialect: "custom-dialect", + }, + NorthernUzbekLanguage.scripts[0] + ); + + test.viewModel.selectScript(NorthernUzbekLanguage.scripts[1]); + + expect(test.viewModel.customizations.value).toEqual({ + customDisplayName: "Custom Uzbek", + }); + }); }); describe("creating unlisted language", () => { @@ -546,6 +579,48 @@ describe("canSubmitOrthography", () => { }); }); +describe("resetTo", () => { + it("restores manually entered tags", () => { + const test = new TestHelper(); + + test.viewModel.resetTo(undefined, "zzz-Foo", "Test"); + + expect(test.viewModel.selectedLanguage.value).toEqual( + expect.objectContaining({ manuallyEnteredTag: "zzz-Foo" }) + ); + expect(test.viewModel.customizations.value).toEqual( + expect.objectContaining({ customDisplayName: "Test" }) + ); + expect(test.viewModel.isReadyToSubmit.value).toBe(true); + }); +}); + +describe("selection change listener", () => { + it("notifies when selection becomes valid and clears when it becomes invalid", () => { + const onSelectionChange = vi.fn(); + const test = new TestHelper({ initialLanguages: [WaataLanguage] }); + + test.viewModel.setSelectionChangeListener(onSelectionChange); + test.viewModel.selectLanguage(WaataLanguage); + + expect(onSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ + language: expect.objectContaining({ + iso639_3_code: WaataLanguage.iso639_3_code, + }), + script: expect.objectContaining({ + code: WaataLanguage.scripts[0].code, + }), + }), + "ssn" + ); + + test.viewModel.onSearchStringChange("ab"); + + expect(onSelectionChange).toHaveBeenLastCalledWith(undefined, undefined); + }); +}); + describe("unlisted language modal", () => { it("opens on customize button clicked when no language is selected", () => { const t = new TestHelper(); diff --git a/components/language-chooser/react/common/language-chooser-react-hook/package.json b/components/language-chooser/react/common/language-chooser-react-hook/package.json index 481c638b..1034c922 100644 --- a/components/language-chooser/react/common/language-chooser-react-hook/package.json +++ b/components/language-chooser/react/common/language-chooser-react-hook/package.json @@ -11,7 +11,7 @@ }, "author": "SIL Global", "license": "MIT", - "version": "0.3.0", + "version": "0.2.0", "main": "./index.js", "types": "./index.d.ts", "scripts": { @@ -23,7 +23,10 @@ "testonce": "nx vite:test --config vitest.config.ts --run" }, "dependencies": { - "@ethnolib/find-language": "0.3.0", + "@ethnolib/find-language": "0.2.0", + "@ethnolib/language-chooser-controller": "0.1.1", + "@ethnolib/state-management-core": "0.1.1", + "@ethnolib/state-management-react": "0.1.0", "fuse.js": "^7.0.0", "iso-15924": "^3.2.0", "iso-3166": "^4.3.0" @@ -35,6 +38,10 @@ "@vitejs/plugin-react-swc": "^3.8.0", "vite-plugin-dts": "^4.2.1" }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, "volta": { "extends": "../../../../../package.json" } diff --git a/components/language-chooser/react/common/language-chooser-react-hook/useLanguageChooser.spec.ts b/components/language-chooser/react/common/language-chooser-react-hook/useLanguageChooser.spec.ts index 29a1f4bf..026c573c 100644 --- a/components/language-chooser/react/common/language-chooser-react-hook/useLanguageChooser.spec.ts +++ b/components/language-chooser/react/common/language-chooser-react-hook/useLanguageChooser.spec.ts @@ -1,5 +1,8 @@ -import { expect, it, describe } from "vitest"; -import { isReadyToSubmit } from "./useLanguageChooser"; +import React from "react"; +import ReactDOM from "react-dom"; +import { act } from "react-dom/test-utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isReadyToSubmit, useLanguageChooser } from "./useLanguageChooser"; import { languageForManuallyEnteredTag, UNLISTED_LANGUAGE, @@ -7,8 +10,44 @@ import { IRegion, IScript, LanguageType, + IOrthography, } from "@ethnolib/find-language"; +function renderUseLanguageChooser( + onSelectionChange?: ( + orthography: IOrthography | undefined, + langtag: string | undefined + ) => void +) { + const container = document.createElement("div"); + document.body.appendChild(container); + + const result: { + current: ReturnType | null; + } = { + current: null, + }; + + function HookHost() { + result.current = useLanguageChooser(onSelectionChange); + return null; + } + + act(() => { + ReactDOM.render(React.createElement(HookHost), container); + }); + + return { + result, + unmount() { + act(() => { + ReactDOM.unmountComponentAtNode(container); + }); + container.remove(); + }, + }; +} + describe("isReadyToSubmit", () => { // Test fixture for IScript const latinScript = { code: "Latn", name: "Latin" } as IScript; @@ -144,6 +183,19 @@ describe("isReadyToSubmit", () => { } ); + it("returns false for unlisted language with whitespace-only region name", () => { + expect( + isReadyToSubmit({ + language: UNLISTED_LANGUAGE, + customDetails: { + customDisplayName: "Test", + region: { ...testRegion, name: " " }, + dialect: "Test Dialect", + }, + }) + ).toBe(false); + }); + it("returns false for unlisted or manually entered tag with no display name", () => { expect( isReadyToSubmit({ @@ -204,3 +256,95 @@ describe("isReadyToSubmit", () => { }); }); }); + +describe("useLanguageChooser", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + const latinScript = { code: "Latn", name: "Latin" } as IScript; + + const singleScriptLanguage = { + autonym: "foo", + exonym: "bar", + iso639_3_code: "baz", + languageSubtag: "foo", + regionNamesForDisplay: "Foobar", + regionNamesForSearch: ["Foobar"], + names: ["foo", "bar", "baz"], + scripts: [latinScript], + alternativeTags: [], + languageType: LanguageType.Living, + } as ILanguage; + + it("binds field-backed state changes and clears selection when searching", () => { + const onSelectionChange = vi.fn(); + const rendered = renderUseLanguageChooser(onSelectionChange); + + act(() => { + rendered.result.current?.selectLanguage(singleScriptLanguage); + }); + + expect(rendered.result.current?.selectedLanguage).toBe( + singleScriptLanguage + ); + expect(rendered.result.current?.selectedScript).toBe(latinScript); + expect(rendered.result.current?.readyToSubmit).toBe(true); + expect(onSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ + language: singleScriptLanguage, + script: latinScript, + }), + expect.any(String) + ); + + act(() => { + rendered.result.current?.onSearchStringChange("ab"); + }); + + expect(rendered.result.current?.searchString).toBe("ab"); + expect(rendered.result.current?.selectedLanguage).toBeUndefined(); + expect(rendered.result.current?.selectedScript).toBeUndefined(); + expect(rendered.result.current?.readyToSubmit).toBe(false); + expect(onSelectionChange).toHaveBeenLastCalledWith(undefined, undefined); + rendered.unmount(); + }); + + it("keeps the implied single script when saving details without an explicit script", () => { + const rendered = renderUseLanguageChooser(); + + act(() => { + rendered.result.current?.selectLanguage(singleScriptLanguage); + }); + + act(() => { + rendered.result.current?.saveLanguageDetails( + { customDisplayName: "Custom Foo" }, + undefined + ); + }); + + expect(rendered.result.current?.selectedScript).toBe(latinScript); + expect(rendered.result.current?.customizableLanguageDetails).toEqual( + expect.objectContaining({ customDisplayName: "Custom Foo" }) + ); + rendered.unmount(); + }); + + it("restores manually entered tags through resetTo", () => { + const rendered = renderUseLanguageChooser(); + + act(() => { + rendered.result.current?.resetTo(undefined, "zzz-Foo", "Test"); + }); + + expect(rendered.result.current?.selectedLanguage).toEqual( + expect.objectContaining({ manuallyEnteredTag: "zzz-Foo" }) + ); + expect(rendered.result.current?.customizableLanguageDetails).toEqual( + expect.objectContaining({ customDisplayName: "Test" }) + ); + expect(rendered.result.current?.readyToSubmit).toBe(true); + rendered.unmount(); + }); +}); diff --git a/components/language-chooser/react/common/language-chooser-react-hook/useLanguageChooser.ts b/components/language-chooser/react/common/language-chooser-react-hook/useLanguageChooser.ts index d3719cee..14960066 100644 --- a/components/language-chooser/react/common/language-chooser-react-hook/useLanguageChooser.ts +++ b/components/language-chooser/react/common/language-chooser-react-hook/useLanguageChooser.ts @@ -1,23 +1,15 @@ import { ILanguage, IScript, - asyncSearchForLanguage, ICustomizableLanguageDetails, - deepStripDemarcation, -} from "@ethnolib/find-language"; -import { useEffect, useRef, useState } from "react"; -import { - isValidBcp47Tag, - isManuallyEnteredTagLanguage, - isUnlistedLanguage, - languageForManuallyEnteredTag, - parseLangtagFromLangChooser, - UNLISTED_LANGUAGE, IOrthography, - createTagFromOrthography, - defaultDisplayName, - formatDialectCode, } from "@ethnolib/find-language"; +import { useEffect, useRef } from "react"; +import { + canSubmitOrthography, + useLanguageChooserViewModel, +} from "@ethnolib/language-chooser-controller"; +import { useField } from "@ethnolib/state-management-react"; export interface ILanguageChooser { languageResults: ILanguage[]; @@ -54,251 +46,64 @@ export const useLanguageChooser = ( searchString: string ) => ILanguage[] ) => { - const [searchString, setSearchString] = useState(""); - // we use useRef to help with asynchronously accessing the up-to-date value from the search function - - // if the user keeps typing we want to cancel the previous searches promptly and the searchString won't have updated yet - // But we still need the searchString state to trigger the useEffect - const searchStringRef = useRef(""); - const [selectedLanguage, setSelectedLanguage] = useState< - ILanguage | undefined - >(); - const [selectedScript, setSelectedScript] = useState(); - - const EMPTY_CUSTOMIZABLE_LANGUAGE_DETAILS = { - customDisplayName: undefined, - region: undefined, - dialect: undefined, - } as ICustomizableLanguageDetails; - - const [customizableLanguageDetails, setCustomizableLanguageDetails] = - useState(EMPTY_CUSTOMIZABLE_LANGUAGE_DETAILS); - - function clearCustomizableLanguageDetails() { - setCustomizableLanguageDetails(EMPTY_CUSTOMIZABLE_LANGUAGE_DETAILS); - } - - const readyToSubmit = isReadyToSubmit({ - language: selectedLanguage, - script: selectedScript, - customDetails: customizableLanguageDetails, - }); - - const [languageResults, setLanguageResults] = useState([]); - - // For faster results, the search function returns better results first but then continues searching for more results - // and appends them to the result list, in several rounds. - // Return true if we should continue searching for more results, and false if we should abort because the search string has changed - function appendResults( - additionalSearchResults: ILanguage[], - forSearchString: string - ) { - if (forSearchString !== searchStringRef.current) { - // Search string has changed, stop looking for results for this search string - return false; - } - const modifier = searchResultModifier || ((r) => r); - //append the new results to the existing results - setLanguageResults((r) => - r.concat(modifier(additionalSearchResults, forSearchString)) - ); - return true; // Keep looking for more results - } - - useEffect(() => { - setLanguageResults([]); - if (!searchString || searchString.length < 2) { - return; - } - (async () => { - await asyncSearchForLanguage(searchString, appendResults); - })(); - }, [searchString]); - - // For reopening to a specific selection - function resetTo( - searchString?: string, - selectionLanguageTag?: string, - initialCustomDisplayName?: string // all info can be captured in language tag except display name - ) { - if (!selectionLanguageTag) { - onSearchStringChange(searchString || ""); - return; - } - - let initialSelections = parseLangtagFromLangChooser( - selectionLanguageTag || "", - searchResultModifier - ); - if (selectionLanguageTag && !initialSelections) { - // we failed to parse the tag, meaning this is a langtag requiring manual entry - initialSelections = { - language: languageForManuallyEnteredTag(selectionLanguageTag || ""), - script: undefined, - customDetails: { - customDisplayName: initialCustomDisplayName, - }, - }; - } - // 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); - } - if (initialSelections?.script) { - selectScript(initialSelections.script); - } - - setCustomizableLanguageDetails({ - ...(initialSelections?.customDetails || - ({} as ICustomizableLanguageDetails)), - // we only save the custom display name if it is different from the default - customDisplayName: - initialCustomDisplayName && - initialCustomDisplayName !== - defaultDisplayName( - initialSelections?.language, - initialSelections?.script - ) - ? initialCustomDisplayName - : undefined, + const stateRef = useRef | null>(null); + if (stateRef.current === null) { + stateRef.current = useLanguageChooserViewModel({ + onSelectionChange, + searchResultModifier, }); } - function saveLanguageDetails( - details: ICustomizableLanguageDetails, - script: IScript | undefined - ) { - setCustomizableLanguageDetails(details); - - // If the provided script is empty but this language only has one script, automatically go back to that implied script - if (!script && selectedLanguage?.scripts.length === 1) { - script = selectedLanguage.scripts[0]; - } - setSelectedScript(script); - } - - function selectLanguage(language: ILanguage) { - setSelectedLanguage(language); - setSelectedScript( - // If there is only one script option for this language, automatically select it - language.scripts.length === 1 ? language.scripts[0] : undefined - ); - clearCustomizableLanguageDetails(); - } - - function selectUnlistedLanguage() { - selectLanguage(UNLISTED_LANGUAGE); - } - - function selectManuallyEnteredTagLanguage(manuallyEnteredTag: string) { - selectLanguage(languageForManuallyEnteredTag(manuallyEnteredTag)); - } - - function clearLanguageSelection() { - setSelectedLanguage(undefined); - setSelectedScript(undefined); - clearCustomizableLanguageDetails(); - } - - function clearCustomizableDetailsExceptDisplayName() { - setCustomizableLanguageDetails((d) => ({ - ...EMPTY_CUSTOMIZABLE_LANGUAGE_DETAILS, - customDisplayName: d.customDisplayName, - })); - } - - function selectScript(script: IScript) { - setSelectedScript(script); - clearCustomizableDetailsExceptDisplayName(); // BL-15918 - } - function clearScriptSelection() { - setSelectedScript(undefined); - clearCustomizableDetailsExceptDisplayName(); // BL-15918 - } - - function onSearchStringChange(newSearchString: string) { - searchStringRef.current = newSearchString; - setSearchString(newSearchString); - setSelectedLanguage(undefined); - setSelectedScript(undefined); - clearCustomizableLanguageDetails(); - } - - const [previousStateWasValidSelection, setPreviousStateWasValidSelection] = - useState(false); + const viewModel = stateRef.current; useEffect(() => { - if (onSelectionChange) { - if (readyToSubmit) { - const resultingOrthography = deepStripDemarcation({ - language: selectedLanguage, - script: selectedScript, - customDetails: customizableLanguageDetails, - }) as IOrthography; - onSelectionChange( - resultingOrthography, - createTagFromOrthography(resultingOrthography) - ); - setPreviousStateWasValidSelection(true); - } else if (previousStateWasValidSelection) { - onSelectionChange(undefined, undefined); - setPreviousStateWasValidSelection(false); - } - } - }, [selectedLanguage, selectedScript, customizableLanguageDetails]); + viewModel.setSelectionChangeListener(onSelectionChange); + viewModel.setSearchResultModifier(searchResultModifier); + }, [onSelectionChange, searchResultModifier]); + + const [searchString] = useField(viewModel.searchString); + const [selectedLanguage] = useField(viewModel.selectedLanguage); + const [selectedScript] = useField(viewModel.selectedScript); + const [customizableLanguageDetailsValue] = useField( + viewModel.customizableLanguageDetails + ); + const [languageResults] = useField(viewModel.languageResults); + const [readyToSubmit] = useField(viewModel.readyToSubmit); + + const customizableLanguageDetails = + customizableLanguageDetailsValue || + createEmptyCustomizableLanguageDetails(); return { languageResults, selectedLanguage, selectedScript, customizableLanguageDetails, - searchString: searchString, - onSearchStringChange, - selectLanguage, - selectUnlistedLanguage, - selectManuallyEnteredTagLanguage, - clearLanguageSelection, - selectScript, - clearScriptSelection, + searchString, + onSearchStringChange: viewModel.onSearchStringChange, + selectLanguage: viewModel.selectLanguage, + selectUnlistedLanguage: viewModel.selectUnlistedLanguage, + selectManuallyEnteredTagLanguage: + viewModel.selectManuallyEnteredTagLanguage, + clearLanguageSelection: viewModel.clearLanguageSelection, + selectScript: viewModel.selectScript, + clearScriptSelection: viewModel.clearScriptSelection, readyToSubmit, - saveLanguageDetails, - resetTo, + saveLanguageDetails: viewModel.saveLanguageDetails, + resetTo: viewModel.resetTo, } as ILanguageChooser; }; -function hasValidDisplayName(selection: IOrthography) { - if (!selection.language) { - return false; - } - // Check that user has not entered an empty string or whitespace only in the custom display name - if ( - typeof selection.customDetails?.customDisplayName === "string" && - !selection.customDetails?.customDisplayName?.trim() - ) { - return false; - } - // Check that we have a default display name and/or a custom display name - return ( - !!defaultDisplayName(selection.language, selection.script) || - !!selection.customDetails?.customDisplayName - ); +function createEmptyCustomizableLanguageDetails(): ICustomizableLanguageDetails { + return { + customDisplayName: undefined, + region: undefined, + dialect: undefined, + } as ICustomizableLanguageDetails; } export function isReadyToSubmit(selection: IOrthography): boolean { - const normalizedDialect = formatDialectCode(selection.customDetails?.dialect); - return ( - !!selection.language && - hasValidDisplayName(selection) && - // either a script is selected or there are no scripts for the selected language - (!!selection.script || selection.language?.scripts?.length === 0) && - // if unlisted language, name and country are required - (!isUnlistedLanguage(selection.language) || - (!!normalizedDialect && !!selection.customDetails?.region?.name)) && - // if this was a manually entered langtag, check that tag is valid BCP 47 - (!isManuallyEnteredTagLanguage(selection.language) || - isValidBcp47Tag(selection.language?.manuallyEnteredTag)) - ); + return canSubmitOrthography(selection); } diff --git a/components/language-chooser/react/common/language-chooser-react-hook/vitest.config.ts b/components/language-chooser/react/common/language-chooser-react-hook/vitest.config.ts index 803a872f..8ccd69fd 100644 --- a/components/language-chooser/react/common/language-chooser-react-hook/vitest.config.ts +++ b/components/language-chooser/react/common/language-chooser-react-hook/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vite"; export default defineConfig({ test: { + environment: "jsdom", expect: { requireAssertions: true, }, diff --git a/components/state-management/state-management-react/package.json b/components/state-management/state-management-react/package.json index 829b0cba..f276284b 100644 --- a/components/state-management/state-management-react/package.json +++ b/components/state-management/state-management-react/package.json @@ -22,13 +22,14 @@ "@ethnolib/state-management-core": "0.2.0" }, "peerDependencies": { - "react": "^17.0.2", - "react-dom": "^17.0.2" + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" }, "devDependencies": { "@types/react": "^17", "@types/react-dom": "^17", "@types/node": "^20.16.11", + "@vitejs/plugin-react-swc": "^3.8.0", "jsdom": "^26.0.0", "tsx": "^4.19.2", "typescript": "^5.2.2" diff --git a/components/state-management/state-management-react/src/use-field.spec.ts b/components/state-management/state-management-react/src/use-field.spec.ts index f5d8a648..6ff9310f 100644 --- a/components/state-management/state-management-react/src/use-field.spec.ts +++ b/components/state-management/state-management-react/src/use-field.spec.ts @@ -5,9 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Field } from "@ethnolib/state-management-core"; import { useField } from "./use-field"; -// This test harness intentionally uses React 17-compatible APIs because the -// package still supports React 17 in peerDependencies. - function renderUseField(field: Field) { const container = document.createElement("div"); document.body.appendChild(container); @@ -120,20 +117,4 @@ describe("useField", () => { expect(rendered.result.current?.[0]).toBe("updated"); rendered.unmount(); }); - - it("keeps the setter stable until the field instance changes", () => { - const firstField = new Field("first"); - const secondField = new Field("second"); - const rendered = renderUseField(firstField); - - const initialSetter = rendered.result.current?.[1]; - rendered.rerender(firstField); - - expect(rendered.result.current?.[1]).toBe(initialSetter); - - rendered.rerender(secondField); - - expect(rendered.result.current?.[1]).not.toBe(initialSetter); - rendered.unmount(); - }); }); \ No newline at end of file diff --git a/components/state-management/state-management-react/src/use-field.ts b/components/state-management/state-management-react/src/use-field.ts index 71849f95..adcc8564 100644 --- a/components/state-management/state-management-react/src/use-field.ts +++ b/components/state-management/state-management-react/src/use-field.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { Field } from "@ethnolib/state-management-core"; type FieldSubscriber = (value: T) => void; @@ -52,9 +52,9 @@ function subscribeToField(field: Field, subscriber: FieldSubscriber) { export function useField(field: Field): [T, (value: T) => void] { const [fieldValue, setFieldValueState] = useState(field.value as T); - const setFieldValue = useCallback((value: T) => { + function setFieldValue(value: T) { field.requestUpdate(value); - }, [field]); + } useEffect(() => { const unsubscribe = subscribeToField(field, (value) => { diff --git a/components/state-management/state-management-react/vite.config.ts b/components/state-management/state-management-react/vite.config.ts index 9e7c630e..b9b99dfd 100644 --- a/components/state-management/state-management-react/vite.config.ts +++ b/components/state-management/state-management-react/vite.config.ts @@ -32,8 +32,5 @@ export default defineConfig({ fileName: "index", formats: ["es", "cjs"], }, - rollupOptions: { - external: ["react", "react-dom", "react/jsx-runtime"], - }, }, }); diff --git a/package-lock.json b/package-lock.json index da7ad4f3..d0550ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -390,6 +390,7 @@ "@types/node": "^20.16.11", "@types/react": "^17", "@types/react-dom": "^17", + "@vitejs/plugin-react-swc": "^3.8.0", "jsdom": "^26.0.0", "tsx": "^4.19.2", "typescript": "^5.2.2" From e78e551bff964d0f856526f6b0b4d3ba702023f2 Mon Sep 17 00:00:00 2001 From: Andrew Polk Date: Tue, 17 Mar 2026 15:04:10 -0700 Subject: [PATCH 2/2] fix: cleanup --- .github/actions/build-and-test/action.yml | 2 +- .../language-chooser-react-hook/package.json | 14 +- .../state-management-react/package.json | 5 +- .../src/use-field.spec.ts | 21 +- .../state-management-react/src/use-field.ts | 6 +- .../state-management-react/vite.config.ts | 3 + package-lock.json | 630 +++++++++++++++--- 7 files changed, 561 insertions(+), 120 deletions(-) diff --git a/.github/actions/build-and-test/action.yml b/.github/actions/build-and-test/action.yml index 7c4f4cc7..2d7d7362 100644 --- a/.github/actions/build-and-test/action.yml +++ b/.github/actions/build-and-test/action.yml @@ -4,7 +4,7 @@ runs: using: composite steps: - name: Build UI packages - run: npx nx run-many --target=build --projects=@ethnolib/state-management-react,@ethnolib/language-chooser-react-mui,@ethnolib/language-chooser-svelte-daisyui + run: npx nx run-many --target=build --projects=@ethnolib/language-chooser-react-mui,@ethnolib/language-chooser-svelte-daisyui shell: bash - name: Run unit and e2e tests in parallel diff --git a/components/language-chooser/react/common/language-chooser-react-hook/package.json b/components/language-chooser/react/common/language-chooser-react-hook/package.json index 1034c922..2e748d37 100644 --- a/components/language-chooser/react/common/language-chooser-react-hook/package.json +++ b/components/language-chooser/react/common/language-chooser-react-hook/package.json @@ -11,7 +11,7 @@ }, "author": "SIL Global", "license": "MIT", - "version": "0.2.0", + "version": "0.3.0", "main": "./index.js", "types": "./index.d.ts", "scripts": { @@ -23,10 +23,10 @@ "testonce": "nx vite:test --config vitest.config.ts --run" }, "dependencies": { - "@ethnolib/find-language": "0.2.0", - "@ethnolib/language-chooser-controller": "0.1.1", - "@ethnolib/state-management-core": "0.1.1", - "@ethnolib/state-management-react": "0.1.0", + "@ethnolib/find-language": "0.3.0", + "@ethnolib/language-chooser-controller": "0.2.0", + "@ethnolib/state-management-core": "0.2.0", + "@ethnolib/state-management-react": "0.1.1", "fuse.js": "^7.0.0", "iso-15924": "^3.2.0", "iso-3166": "^4.3.0" @@ -38,10 +38,6 @@ "@vitejs/plugin-react-swc": "^3.8.0", "vite-plugin-dts": "^4.2.1" }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" - }, "volta": { "extends": "../../../../../package.json" } diff --git a/components/state-management/state-management-react/package.json b/components/state-management/state-management-react/package.json index f276284b..829b0cba 100644 --- a/components/state-management/state-management-react/package.json +++ b/components/state-management/state-management-react/package.json @@ -22,14 +22,13 @@ "@ethnolib/state-management-core": "0.2.0" }, "peerDependencies": { - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^17.0.2", + "react-dom": "^17.0.2" }, "devDependencies": { "@types/react": "^17", "@types/react-dom": "^17", "@types/node": "^20.16.11", - "@vitejs/plugin-react-swc": "^3.8.0", "jsdom": "^26.0.0", "tsx": "^4.19.2", "typescript": "^5.2.2" diff --git a/components/state-management/state-management-react/src/use-field.spec.ts b/components/state-management/state-management-react/src/use-field.spec.ts index 6ff9310f..966039df 100644 --- a/components/state-management/state-management-react/src/use-field.spec.ts +++ b/components/state-management/state-management-react/src/use-field.spec.ts @@ -5,6 +5,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Field } from "@ethnolib/state-management-core"; import { useField } from "./use-field"; +// This test harness intentionally uses React 17-compatible APIs because the +// package still supports React 17 in peerDependencies. + function renderUseField(field: Field) { const container = document.createElement("div"); document.body.appendChild(container); @@ -117,4 +120,20 @@ describe("useField", () => { expect(rendered.result.current?.[0]).toBe("updated"); rendered.unmount(); }); -}); \ No newline at end of file + + it("keeps the setter stable until the field instance changes", () => { + const firstField = new Field("first"); + const secondField = new Field("second"); + const rendered = renderUseField(firstField); + + const initialSetter = rendered.result.current?.[1]; + rendered.rerender(firstField); + + expect(rendered.result.current?.[1]).toBe(initialSetter); + + rendered.rerender(secondField); + + expect(rendered.result.current?.[1]).not.toBe(initialSetter); + rendered.unmount(); + }); +}); diff --git a/components/state-management/state-management-react/src/use-field.ts b/components/state-management/state-management-react/src/use-field.ts index adcc8564..71849f95 100644 --- a/components/state-management/state-management-react/src/use-field.ts +++ b/components/state-management/state-management-react/src/use-field.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Field } from "@ethnolib/state-management-core"; type FieldSubscriber = (value: T) => void; @@ -52,9 +52,9 @@ function subscribeToField(field: Field, subscriber: FieldSubscriber) { export function useField(field: Field): [T, (value: T) => void] { const [fieldValue, setFieldValueState] = useState(field.value as T); - function setFieldValue(value: T) { + const setFieldValue = useCallback((value: T) => { field.requestUpdate(value); - } + }, [field]); useEffect(() => { const unsubscribe = subscribeToField(field, (value) => { diff --git a/components/state-management/state-management-react/vite.config.ts b/components/state-management/state-management-react/vite.config.ts index b9b99dfd..9e7c630e 100644 --- a/components/state-management/state-management-react/vite.config.ts +++ b/components/state-management/state-management-react/vite.config.ts @@ -32,5 +32,8 @@ export default defineConfig({ fileName: "index", formats: ["es", "cjs"], }, + rollupOptions: { + external: ["react", "react-dom", "react/jsx-runtime"], + }, }, }); diff --git a/package-lock.json b/package-lock.json index d0550ea0..48a2106a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,6 +120,9 @@ "license": "MIT", "dependencies": { "@ethnolib/find-language": "0.3.0", + "@ethnolib/language-chooser-controller": "0.2.0", + "@ethnolib/state-management-core": "0.2.0", + "@ethnolib/state-management-react": "0.1.1", "fuse.js": "^7.0.0", "iso-15924": "^3.2.0", "iso-3166": "^4.3.0" @@ -163,6 +166,18 @@ "react-dom": "^17.0.2" } }, + "components/language-chooser/react/language-chooser-react-mui/node_modules/@ethnolib/language-chooser-react-hook": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@ethnolib/language-chooser-react-hook/-/language-chooser-react-hook-0.3.0.tgz", + "integrity": "sha512-520GQADRcSeLp825DkKVN+g67qKEU80NBIMGxHJDlzxwdxG7XBdDYgQjnQlH0EYfZ4iEYgh4hOEB1eTgys22vA==", + "license": "MIT", + "dependencies": { + "@ethnolib/find-language": "0.3.0", + "fuse.js": "^7.0.0", + "iso-15924": "^3.2.0", + "iso-3166": "^4.3.0" + } + }, "components/language-chooser/svelte/language-chooser-svelte-daisyui": { "name": "@ethnolib/language-chooser-svelte-daisyui", "version": "0.2.0", @@ -390,7 +405,6 @@ "@types/node": "^20.16.11", "@types/react": "^17", "@types/react-dom": "^17", - "@vitejs/plugin-react-swc": "^3.8.0", "jsdom": "^26.0.0", "tsx": "^4.19.2", "typescript": "^5.2.2" @@ -454,7 +468,7 @@ }, "node_modules/@ampproject/remapping": { "version": "2.3.0", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -483,7 +497,6 @@ }, "node_modules/@babel/code-frame": { "version": "7.27.1", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -496,7 +509,7 @@ }, "node_modules/@babel/compat-data": { "version": "7.28.0", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -504,7 +517,7 @@ }, "node_modules/@babel/core": { "version": "7.28.3", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -533,12 +546,12 @@ }, "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -546,7 +559,6 @@ }, "node_modules/@babel/generator": { "version": "7.28.3", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.3", @@ -572,7 +584,7 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.27.2", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -587,7 +599,7 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -662,7 +674,6 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -682,7 +693,6 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.27.1", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -694,7 +704,7 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.3", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -773,7 +783,6 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -781,7 +790,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.27.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -789,7 +797,7 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -810,7 +818,7 @@ }, "node_modules/@babel/helpers": { "version": "7.28.3", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -822,7 +830,6 @@ }, "node_modules/@babel/parser": { "version": "7.28.3", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -2113,7 +2120,6 @@ }, "node_modules/@babel/template": { "version": "7.27.2", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -2126,7 +2132,6 @@ }, "node_modules/@babel/traverse": { "version": "7.28.3", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -2143,7 +2148,6 @@ }, "node_modules/@babel/types": { "version": "7.28.2", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2323,6 +2327,165 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT", + "peer": true + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -3448,7 +3611,7 @@ }, "node_modules/@jest/schemas": { "version": "29.6.3", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" @@ -3530,7 +3693,7 @@ }, "node_modules/@jest/types": { "version": "29.6.3", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -3643,7 +3806,6 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3661,7 +3823,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3669,12 +3830,10 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.30", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3691,7 +3850,7 @@ }, "node_modules/@lingui/babel-plugin-lingui-macro": { "version": "5.4.1", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/core": "^7.20.12", @@ -3792,7 +3951,7 @@ }, "node_modules/@lingui/conf": { "version": "5.4.1", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.13", @@ -4087,6 +4246,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, "node_modules/@mui/icons-material": { "version": "5.18.0", "license": "MIT", @@ -4111,6 +4281,201 @@ } } }, + "node_modules/@mui/material": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.17.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/types": "~7.2.15", + "@types/prop-types": "^15.7.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.4", "dev": true, @@ -4602,6 +4967,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "dev": true, @@ -5090,7 +5466,7 @@ }, "node_modules/@sinclair/typebox": { "version": "0.27.8", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@sinonjs/commons": { @@ -6404,12 +6780,12 @@ }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" @@ -6417,7 +6793,7 @@ }, "node_modules/@types/istanbul-reports": { "version": "3.0.4", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" @@ -6440,7 +6816,7 @@ }, "node_modules/@types/node": { "version": "24.3.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.10.0" @@ -6448,12 +6824,10 @@ }, "node_modules/@types/parse-json": { "version": "4.0.2", - "dev": true, "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", - "dev": true, "license": "MIT" }, "node_modules/@types/pug": { @@ -6463,7 +6837,6 @@ }, "node_modules/@types/react": { "version": "17.0.88", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -6487,6 +6860,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.6", "dev": true, @@ -6494,7 +6877,6 @@ }, "node_modules/@types/scheduler": { "version": "0.16.8", - "dev": true, "license": "MIT" }, "node_modules/@types/stack-utils": { @@ -6522,7 +6904,7 @@ }, "node_modules/@types/yargs": { "version": "17.0.33", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -6530,7 +6912,7 @@ }, "node_modules/@types/yargs-parser": { "version": "21.0.3", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -7658,7 +8040,7 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -7721,7 +8103,7 @@ }, "node_modules/argparse": { "version": "2.0.1", - "dev": true, + "devOptional": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -8048,7 +8430,6 @@ }, "node_modules/babel-plugin-macros": { "version": "3.1.0", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", @@ -8062,7 +8443,6 @@ }, "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { "version": "7.1.0", - "dev": true, "license": "MIT", "dependencies": { "@types/parse-json": "^4.0.0", @@ -8259,7 +8639,7 @@ }, "node_modules/browserslist": { "version": "4.25.4", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -8438,7 +8818,6 @@ }, "node_modules/callsites": { "version": "3.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8462,7 +8841,7 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001739", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -8496,7 +8875,7 @@ }, "node_modules/chalk": { "version": "4.1.2", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -8669,7 +9048,6 @@ }, "node_modules/clsx": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8691,7 +9069,7 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -8702,7 +9080,7 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/colorette": { @@ -8821,7 +9199,6 @@ }, "node_modules/convert-source-map": { "version": "1.9.0", - "dev": true, "license": "MIT" }, "node_modules/core-js-compat": { @@ -8846,7 +9223,7 @@ }, "node_modules/cosmiconfig": { "version": "8.3.6", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "import-fresh": "^3.3.0", @@ -8932,7 +9309,6 @@ }, "node_modules/csstype": { "version": "3.1.3", - "dev": true, "license": "MIT" }, "node_modules/cwd": { @@ -9062,7 +9438,6 @@ }, "node_modules/debug": { "version": "4.4.1", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -9299,6 +9674,17 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "0.2.2", "dev": true, @@ -9408,7 +9794,7 @@ }, "node_modules/electron-to-chromium": { "version": "1.5.212", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/emittery": { @@ -9465,7 +9851,6 @@ }, "node_modules/error-ex": { "version": "1.3.2", - "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -9700,7 +10085,7 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -9708,7 +10093,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10517,6 +10901,13 @@ "node": ">=18" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT", + "peer": true + }, "node_modules/find-up": { "version": "5.0.0", "dev": true, @@ -10728,7 +11119,6 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10775,7 +11165,7 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -11014,7 +11404,7 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -11095,7 +11485,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -11112,6 +11501,23 @@ "he": "bin/he" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT", + "peer": true + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "dev": true, @@ -11391,7 +11797,6 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -11519,7 +11924,6 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", - "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -11593,7 +11997,6 @@ }, "node_modules/is-core-module": { "version": "2.16.1", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -12376,7 +12779,7 @@ }, "node_modules/jest-get-type": { "version": "29.6.3", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -12819,7 +13222,7 @@ }, "node_modules/jest-validate": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -12835,7 +13238,7 @@ }, "node_modules/jest-validate/node_modules/camelcase": { "version": "6.3.0", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -12993,7 +13396,7 @@ }, "node_modules/jiti": { "version": "1.21.7", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -13029,12 +13432,11 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -13144,7 +13546,6 @@ }, "node_modules/jsesc": { "version": "3.1.0", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -13160,7 +13561,6 @@ }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -13175,7 +13575,7 @@ }, "node_modules/json5": { "version": "2.2.3", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -13259,7 +13659,7 @@ }, "node_modules/leven": { "version": "3.1.0", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -13315,6 +13715,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13437,7 +13838,6 @@ }, "node_modules/loose-envify": { "version": "1.4.0", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -13453,7 +13853,7 @@ }, "node_modules/lru-cache": { "version": "5.1.1", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -13702,7 +14102,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "dev": true, "license": "MIT" }, "node_modules/muggle-string": { @@ -13784,7 +14183,7 @@ }, "node_modules/node-releases": { "version": "2.0.19", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/normalize-path": { @@ -14233,7 +14632,6 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14509,7 +14907,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -14520,7 +14917,6 @@ }, "node_modules/parse-json": { "version": "5.2.0", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -14537,7 +14933,6 @@ }, "node_modules/parse-json/node_modules/lines-and-columns": { "version": "1.2.4", - "dev": true, "license": "MIT" }, "node_modules/parse-passwd": { @@ -14601,7 +14996,6 @@ }, "node_modules/path-parse": { "version": "1.0.7", - "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -14629,7 +15023,6 @@ }, "node_modules/path-type": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14655,7 +15048,6 @@ }, "node_modules/picocolors": { "version": "1.1.1", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -15049,7 +15441,7 @@ }, "node_modules/pretty-format": { "version": "29.7.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -15062,7 +15454,7 @@ }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -15073,7 +15465,7 @@ }, "node_modules/pretty-format/node_modules/react-is": { "version": "18.3.1", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/proc-log": { @@ -15125,7 +15517,6 @@ }, "node_modules/prop-types": { "version": "15.8.1", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -15135,7 +15526,6 @@ }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", - "dev": true, "license": "MIT" }, "node_modules/proxy-from-env": { @@ -15230,7 +15620,6 @@ }, "node_modules/react": { "version": "17.0.2", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -15270,7 +15659,6 @@ }, "node_modules/react-dom": { "version": "17.0.2", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -15281,6 +15669,13 @@ "react": "17.0.2" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, "node_modules/react-lazyload": { "version": "3.2.1", "license": "MIT", @@ -15289,6 +15684,23 @@ "react-dom": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "dev": true, @@ -15514,7 +15926,6 @@ }, "node_modules/resolve": { "version": "1.22.10", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -15564,7 +15975,6 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -15894,7 +16304,6 @@ }, "node_modules/scheduler": { "version": "0.20.2", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -16187,6 +16596,16 @@ "node": ">=8.0.0" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "dev": true, @@ -16621,6 +17040,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT", + "peer": true + }, "node_modules/sucrase": { "version": "3.35.0", "dev": true, @@ -16724,7 +17150,7 @@ }, "node_modules/supports-color": { "version": "7.2.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -16735,7 +17161,6 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -17699,7 +18124,7 @@ }, "node_modules/undici-types": { "version": "7.10.0", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -17770,7 +18195,7 @@ }, "node_modules/update-browserslist-db": { "version": "1.1.3", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -18942,12 +19367,11 @@ }, "node_modules/yallist": { "version": "3.1.1", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/yaml": { "version": "1.10.2", - "dev": true, "license": "ISC", "engines": { "node": ">= 6"