From f9ffc82221b7a52ba6bed70d22e622a20fab6e9a Mon Sep 17 00:00:00 2001 From: Matt Luongo Date: Mon, 12 Jan 2026 15:19:56 -0500 Subject: [PATCH 1/3] Add @thesis-co/cent-react package scaffolding and README --- packages/cent-react/COPYRIGHT | 3 + packages/cent-react/LICENSE | 21 + packages/cent-react/README.md | 241 ++++++ packages/cent-react/jest.config.js | 15 + packages/cent-react/package.json | 55 ++ packages/cent-react/tsconfig.json | 12 + pnpm-lock.yaml | 1230 ++++++++++++++++++++++++++++ 7 files changed, 1577 insertions(+) create mode 100644 packages/cent-react/COPYRIGHT create mode 100644 packages/cent-react/LICENSE create mode 100644 packages/cent-react/README.md create mode 100644 packages/cent-react/jest.config.js create mode 100644 packages/cent-react/package.json create mode 100644 packages/cent-react/tsconfig.json diff --git a/packages/cent-react/COPYRIGHT b/packages/cent-react/COPYRIGHT new file mode 100644 index 0000000..b9ae061 --- /dev/null +++ b/packages/cent-react/COPYRIGHT @@ -0,0 +1,3 @@ +Copyright (c) 2026 Thesis, Inc. + +All rights reserved. diff --git a/packages/cent-react/LICENSE b/packages/cent-react/LICENSE new file mode 100644 index 0000000..669a169 --- /dev/null +++ b/packages/cent-react/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Thesis, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cent-react/README.md b/packages/cent-react/README.md new file mode 100644 index 0000000..7db3362 --- /dev/null +++ b/packages/cent-react/README.md @@ -0,0 +1,241 @@ +# @thesis-co/cent-react + +React bindings for [@thesis-co/cent](https://www.npmjs.com/package/@thesis-co/cent) - display, input, and manage money values with ease. + +## Installation + +```bash +npm install @thesis-co/cent @thesis-co/cent-react +``` + +## Quick Start + +### Display Money + +```tsx +import { MoneyDisplay } from '@thesis-co/cent-react'; +import { Money } from '@thesis-co/cent'; + +// Basic usage + +// → "$1,234.56" + +// Compact notation + +// → "$1.5M" + +// Crypto with satoshis + +// → "100,000 sats" + +// Locale formatting + +// → "1.234,56 €" + +// Null handling with placeholder + +// → "—" +``` + +### Custom Parts Rendering + +```tsx + + {({ parts, isNegative }) => ( + + {parts.map((part, i) => ( + + {part.value} + + ))} + + )} + +``` + +### Money Input + +```tsx +import { MoneyInput } from '@thesis-co/cent-react'; +import { Money } from '@thesis-co/cent'; + +function PaymentForm() { + const [amount, setAmount] = useState(null); + + return ( + setAmount(e.target.value)} + currency="USD" + min="$1" + max="$10000" + placeholder="Enter amount" + /> + ); +} +``` + +### With react-hook-form + +```tsx +import { Controller, useForm } from 'react-hook-form'; +import { MoneyInput } from '@thesis-co/cent-react'; + +function CheckoutForm() { + const { control, handleSubmit } = useForm(); + + return ( +
+ ( + + )} + /> + + ); +} +``` + +### useMoney Hook + +```tsx +import { useMoney, MoneyDisplay } from '@thesis-co/cent-react'; + +function TipCalculator() { + const bill = useMoney({ currency: 'USD' }); + const tip = bill.money?.multiply(0.18) ?? null; + + return ( +
+ + {bill.error && {bill.error.message}} + +

Tip (18%):

+

Total:

+
+ ); +} +``` + +### MoneyProvider + +Set default configuration for all descendant components: + +```tsx +import { MoneyProvider } from '@thesis-co/cent-react'; + +function App() { + return ( + + + + ); +} +``` + +### useExchangeRate Hook + +```tsx +import { useExchangeRate, MoneyDisplay } from '@thesis-co/cent-react'; +import { Money } from '@thesis-co/cent'; + +function CurrencyConverter() { + const [usd, setUsd] = useState(Money.zero('USD')); + + const { convert, isLoading, isStale, refetch } = useExchangeRate({ + from: 'USD', + to: 'EUR', + pollInterval: 60000, // Refresh every minute + staleThreshold: 300000, // Stale after 5 minutes + }); + + const eur = convert(usd); + + return ( +
+ setUsd(e.target.value)} currency="USD" /> + + {isLoading ? ( + Loading... + ) : ( + + )} + + {isStale && ( + + )} +
+ ); +} +``` + +**Note:** `useExchangeRate` requires an `exchangeRateResolver` to be provided via `MoneyProvider`: + +```tsx + { + const response = await fetch(`/api/rates/${from}/${to}`); + const data = await response.json(); + return new ExchangeRate(from, to, data.rate); + }} +> + + +``` + +### MoneyDiff + +Display the difference between two money values: + +```tsx +import { MoneyDiff } from '@thesis-co/cent-react'; +import { Money } from '@thesis-co/cent'; + +// Basic difference + +// → "+$20.00" + +// With percentage change + +// → "+$20.00 (+20.00%)" + +// Custom rendering + + {({ direction, formatted }) => ( + + {formatted.difference} + + )} + +``` + +## API Reference + +### Components + +| Component | Description | +|-----------|-------------| +| `MoneyDisplay` | Display formatted money values | +| `MoneyInput` | Controlled input for money values | +| `MoneyDiff` | Display difference between two values | +| `MoneyProvider` | Context provider for default configuration | + +### Hooks + +| Hook | Description | +|------|-------------| +| `useMoney` | Manage money state with validation | +| `useExchangeRate` | Fetch and manage exchange rates | +| `useMoneyConfig` | Access MoneyProvider context | + +## Requirements + +- React 17.0.0 or later +- @thesis-co/cent 0.0.5 or later diff --git a/packages/cent-react/jest.config.js b/packages/cent-react/jest.config.js new file mode 100644 index 0000000..798a558 --- /dev/null +++ b/packages/cent-react/jest.config.js @@ -0,0 +1,15 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + testMatch: ['**/test/**/*.test.ts', '**/test/**/*.test.tsx'], + setupFilesAfterEnv: ['/test/setup.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + tsconfig: './tsconfig.json', + }, + ], + }, +} diff --git a/packages/cent-react/package.json b/packages/cent-react/package.json new file mode 100644 index 0000000..e9c5275 --- /dev/null +++ b/packages/cent-react/package.json @@ -0,0 +1,55 @@ +{ + "name": "@thesis-co/cent-react", + "version": "0.0.1", + "description": "React bindings for @thesis-co/cent - display, input, and manage money values", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/thesis/cent.git", + "directory": "packages/cent-react" + }, + "keywords": [ + "react", + "money", + "currency", + "finance", + "input", + "form", + "cents" + ], + "author": "Matt Luongo (@mhluongo)", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "scripts": { + "lint": "pnpx @biomejs/biome check", + "lint:fix": "pnpx @biomejs/biome check --write", + "build": "tsc", + "test": "jest", + "prepublishOnly": "pnpm run build && pnpm run test && pnpm run lint" + }, + "devDependencies": { + "@thesis-co/cent": "workspace:*", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^14.2.0", + "@testing-library/user-event": "^14.5.0", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.24", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "ts-jest": "^29.1.2" + }, + "peerDependencies": { + "@thesis-co/cent": ">=0.0.5", + "react": ">=17.0.0" + } +} diff --git a/packages/cent-react/tsconfig.json b/packages/cent-react/tsconfig.json new file mode 100644 index 0000000..499309a --- /dev/null +++ b/packages/cent-react/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa655c8..0ce11b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,48 @@ importers: specifier: ^29.1.2 version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + packages/cent-react: + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.4.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^14.2.0 + version: 14.3.1(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.5.0 + version: 14.6.1(@testing-library/dom@9.3.4) + '@thesis-co/cent': + specifier: workspace:* + version: link:../cent + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^20.11.24 + version: 20.19.9 + '@types/react': + specifier: ^18.2.0 + version: 18.3.27 + '@types/react-dom': + specifier: ^18.2.0 + version: 18.3.7(@types/react@18.3.27) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.9) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + ts-jest: + specifier: ^29.1.2 + version: 29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4) + packages/cent-zod: devDependencies: '@thesis-co/cent': @@ -60,6 +102,9 @@ importers: packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -214,6 +259,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -378,11 +427,39 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@testing-library/dom@9.3.4': + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} + engines: {node: '>=14'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@14.3.1': + resolution: {integrity: sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==} + engines: {node: '>=14'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@thesis-co/biome-config@https://codeload.github.com/thesis/biome-config/tar.gz/6e8586bfa74c62c9ede2ca12abbcac1dc0ad4606': resolution: {tarball: https://codeload.github.com/thesis/biome-config/tar.gz/6e8586bfa74c62c9ede2ca12abbcac1dc0ad4606} version: 0.0.1 engines: {node: '>=14.0.0'} + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -410,18 +487,55 @@ packages: '@types/jest@29.5.14': resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/node@20.19.9': resolution: {integrity: sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.27': + resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -445,9 +559,27 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -501,6 +633,18 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -549,6 +693,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -564,6 +712,26 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssom@0.3.8: + resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} + + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + + cssstyle@2.3.0: + resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} + engines: {node: '>=8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -573,6 +741,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.6.0: resolution: {integrity: sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==} peerDependencies: @@ -581,10 +752,26 @@ packages: babel-plugin-macros: optional: true + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -593,6 +780,21 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -608,9 +810,32 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -619,11 +844,24 @@ packages: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -653,6 +891,14 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -664,6 +910,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -672,10 +921,18 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -684,24 +941,59 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -711,6 +1003,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -718,13 +1014,41 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -733,14 +1057,56 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -815,6 +1181,15 @@ packages: resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -909,6 +1284,15 @@ packages: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -940,9 +1324,17 @@ packages: lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -953,6 +1345,10 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -960,10 +1356,22 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -991,6 +1399,25 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1018,6 +1445,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1048,6 +1478,14 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1056,16 +1494,49 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -1083,6 +1554,20 @@ packages: engines: {node: '>= 0.4'} hasBin: true + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1092,6 +1577,14 @@ packages: engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1100,6 +1593,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -1124,6 +1633,10 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -1144,6 +1657,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1160,6 +1677,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -1171,6 +1691,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + ts-jest@29.4.0: resolution: {integrity: sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} @@ -1252,19 +1780,59 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1281,6 +1849,25 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1305,6 +1892,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.12 @@ -1474,6 +2063,8 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -1730,10 +2321,48 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@testing-library/dom@9.3.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@14.3.1(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 9.3.4 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + + '@testing-library/user-event@14.6.1(@testing-library/dom@9.3.4)': + dependencies: + '@testing-library/dom': 9.3.4 + '@thesis-co/biome-config@https://codeload.github.com/thesis/biome-config/tar.gz/6e8586bfa74c62c9ede2ca12abbcac1dc0ad4606': dependencies: '@biomejs/biome': 2.1.1 + '@tootallnate/once@2.0.0': {} + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.0 @@ -1774,18 +2403,56 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 20.19.9 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/node@20.19.9': dependencies: undici-types: 6.21.0 + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.27)': + dependencies: + '@types/react': 18.3.27 + + '@types/react@18.3.27': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/stack-utils@2.0.3': {} + '@types/tough-cookie@4.0.5': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': dependencies: '@types/yargs-parser': 21.0.3 + abab@2.0.6: {} + + acorn-globals@7.0.1: + dependencies: + acorn: 8.15.0 + acorn-walk: 8.3.4 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -1807,8 +2474,25 @@ snapshots: dependencies: sprintf-js: 1.0.3 + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + async@3.2.6: {} + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + babel-jest@29.7.0(@babel/core@7.28.0): dependencies: '@babel/core': 7.28.0 @@ -1896,6 +2580,23 @@ snapshots: buffer-from@1.1.2: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} camelcase@5.3.1: {} @@ -1931,6 +2632,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -1956,18 +2661,87 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + + cssom@0.3.8: {} + + cssom@0.5.0: {} + + cssstyle@2.3.0: + dependencies: + cssom: 0.3.8 + + csstype@3.2.3: {} + + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + debug@4.4.1: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + dedent@1.6.0: {} + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + deepmerge@4.3.1: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + detect-newline@3.1.0: {} diff-sequences@29.6.3: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + ejs@3.1.10: dependencies: jake: 10.9.2 @@ -1978,16 +2752,57 @@ snapshots: emoji-regex@8.0.0: {} + entities@6.0.1: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.8 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + escalade@3.2.0: {} escape-string-regexp@2.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + esprima@4.0.1: {} + estraverse@5.3.0: {} + + esutils@2.0.3: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -2029,6 +2844,18 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -2036,12 +2863,32 @@ snapshots: function-bind@1.1.2: {} + functions-have-names@1.2.3: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-package-type@0.1.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stream@6.0.1: {} glob@7.2.3: @@ -2053,18 +2900,55 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-bigints@1.1.0: {} + has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + html-escaper@2.0.2: {} + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -2072,6 +2956,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -2079,20 +2965,95 @@ snapshots: inherits@2.0.4: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-fullwidth-code-point@3.0.0: {} is-generator-fn@2.1.0: {} + is-map@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-weakmap@2.0.2: {} + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -2243,6 +3204,21 @@ snapshots: jest-util: 29.7.0 pretty-format: 29.7.0 + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 20.19.9 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jest-environment-node@29.7.0: dependencies: '@jest/environment': 29.7.0 @@ -2458,6 +3434,39 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.15.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.1.0 + form-data: 4.0.5 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.19.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -2476,10 +3485,16 @@ snapshots: lodash.memoize@4.1.2: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + make-dir@4.0.0: dependencies: semver: 7.7.2 @@ -2490,6 +3505,8 @@ snapshots: dependencies: tmpl: 1.0.5 + math-intrinsics@1.1.0: {} + merge-stream@2.0.0: {} micromatch@4.0.8: @@ -2497,8 +3514,16 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-fn@2.1.0: {} + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -2521,6 +3546,26 @@ snapshots: dependencies: path-key: 3.1.1 + nwsapi@2.2.23: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -2550,6 +3595,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -2568,6 +3617,14 @@ snapshots: dependencies: find-up: 4.1.0 + possible-typed-array-names@1.1.0: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -2579,12 +3636,48 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + pure-rand@6.1.0: {} + querystringify@2.2.0: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@17.0.2: {} + react-is@18.3.1: {} + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + require-directory@2.1.1: {} + requires-port@1.0.0: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -2599,16 +3692,76 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + semver@6.3.1: {} semver@7.7.2: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} sisteransi@1.0.5: {} @@ -2628,6 +3781,11 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -2647,6 +3805,10 @@ snapshots: strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -2659,6 +3821,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 @@ -2671,6 +3835,17 @@ snapshots: dependencies: is-number: 7.0.0 + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@3.0.0: + dependencies: + punycode: 2.3.1 + ts-jest@29.4.0(@babel/core@7.28.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.9))(typescript@5.5.4): dependencies: bs-logger: 0.2.6 @@ -2728,22 +3903,71 @@ snapshots: undici-types@6.21.0: {} + universalify@0.2.0: {} + update-browserslist-db@1.1.3(browserslist@4.25.1): dependencies: browserslist: 4.25.1 escalade: 3.2.0 picocolors: 1.1.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.29 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@3.0.0: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2761,6 +3985,12 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + ws@8.19.0: {} + + xml-name-validator@4.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} From b15f285581eda090ba19bd4d83b9cb21c1f5e35c Mon Sep 17 00:00:00 2001 From: Matt Luongo Date: Mon, 12 Jan 2026 15:20:02 -0500 Subject: [PATCH 2/3] Implement React components and hooks for cent types --- .../cent-react/src/components/MoneyDiff.tsx | 246 +++++++++++++++ .../src/components/MoneyDisplay.tsx | 293 ++++++++++++++++++ .../cent-react/src/components/MoneyInput.tsx | 286 +++++++++++++++++ packages/cent-react/src/components/index.ts | 8 + .../cent-react/src/context/MoneyProvider.tsx | 120 +++++++ packages/cent-react/src/context/index.ts | 6 + packages/cent-react/src/hooks/index.ts | 7 + .../cent-react/src/hooks/useExchangeRate.ts | 198 ++++++++++++ packages/cent-react/src/hooks/useMoney.ts | 266 ++++++++++++++++ .../cent-react/src/hooks/useMoneyConfig.ts | 2 + packages/cent-react/src/index.ts | 22 ++ 11 files changed, 1454 insertions(+) create mode 100644 packages/cent-react/src/components/MoneyDiff.tsx create mode 100644 packages/cent-react/src/components/MoneyDisplay.tsx create mode 100644 packages/cent-react/src/components/MoneyInput.tsx create mode 100644 packages/cent-react/src/components/index.ts create mode 100644 packages/cent-react/src/context/MoneyProvider.tsx create mode 100644 packages/cent-react/src/context/index.ts create mode 100644 packages/cent-react/src/hooks/index.ts create mode 100644 packages/cent-react/src/hooks/useExchangeRate.ts create mode 100644 packages/cent-react/src/hooks/useMoney.ts create mode 100644 packages/cent-react/src/hooks/useMoneyConfig.ts create mode 100644 packages/cent-react/src/index.ts diff --git a/packages/cent-react/src/components/MoneyDiff.tsx b/packages/cent-react/src/components/MoneyDiff.tsx new file mode 100644 index 0000000..217bcda --- /dev/null +++ b/packages/cent-react/src/components/MoneyDiff.tsx @@ -0,0 +1,246 @@ +import { Money, MoneyClass, FixedPointNumber } from '@thesis-co/cent' +import { type ReactNode, useMemo } from 'react' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +/** Options for formatting Money to string */ +interface MoneyFormatOptions { + locale?: string + compact?: boolean + maxDecimals?: number | bigint + minDecimals?: number | bigint + preferredUnit?: string + preferSymbol?: boolean + preferFractionalSymbol?: boolean + excludeCurrency?: boolean +} + +/** + * Render props for MoneyDiff custom rendering + */ +export interface MoneyDiffRenderProps { + /** The current value */ + current: MoneyInstance + /** The comparison value */ + compareTo: MoneyInstance + /** The difference (current - compareTo) */ + difference: MoneyInstance + /** Percentage change as string for precision (null if compareTo is zero) */ + percentageChange: string | null + /** Direction of change */ + direction: 'increase' | 'decrease' | 'unchanged' + /** Formatted strings */ + formatted: { + current: string + compareTo: string + difference: string + percentage: string + } +} + +export interface MoneyDiffProps { + /** Current/new value */ + value: MoneyInstance | string + + /** Value to compare against (previous/baseline) */ + compareTo: MoneyInstance | string + + /** Formatting options */ + formatOptions?: MoneyFormatOptions + + /** Show percentage change */ + showPercentage?: boolean + + /** Number of decimal places for percentage */ + percentageDecimals?: number + + /** CSS class name */ + className?: string + + /** Inline styles */ + style?: React.CSSProperties + + /** Element type to render (default: "span") */ + as?: React.ElementType + + /** Custom render function */ + children?: (props: MoneyDiffRenderProps) => ReactNode +} + +/** + * Coerce a value to Money + */ +function toMoney(value: MoneyInstance | string): MoneyInstance { + if (typeof value === 'string') { + return Money(value) as MoneyInstance + } + return value +} + +/** + * Calculate percentage change using FixedPointNumber for precision. + * Returns the percentage as a string to preserve precision. + */ +function calculatePercentageChange(current: MoneyInstance, compareTo: MoneyInstance, decimals: number): string | null { + if (compareTo.isZero()) { + return null + } + + try { + // (current - compareTo) / |compareTo| * 100 + const diff = current.subtract(compareTo) + const diffStr = diff.toString({ excludeCurrency: true }).replace(/,/g, '') + const compareStr = compareTo.absolute().toString({ excludeCurrency: true }).replace(/,/g, '') + + const diffFP = FixedPointNumber.fromDecimalString(diffStr) + const compareFP = FixedPointNumber.fromDecimalString(compareStr) + + if (compareFP.amount === 0n) return null + + // Align to same decimal scale + const maxDecimals = diffFP.decimals > compareFP.decimals ? diffFP.decimals : compareFP.decimals + const diffScaled = diffFP.amount * 10n ** (maxDecimals - diffFP.decimals) + const compareScaled = compareFP.amount * 10n ** (maxDecimals - compareFP.decimals) + + // For percentage with N decimal places, we need extra precision + // percentage = (diff / compare) * 100 + // We compute: (diff * 100 * 10^(decimals+1)) / compare, then round + const extraPrecision = BigInt(decimals + 1) + const multiplier = 100n * 10n ** extraPrecision + + const rawResult = (diffScaled * multiplier) / compareScaled + + // Create a FixedPointNumber with the result and use its toString for formatting + const resultFP = new FixedPointNumber(rawResult, extraPrecision) + + // Normalize to desired decimals (truncating extra precision) + const targetFP = new FixedPointNumber(0n, BigInt(decimals)) + const normalizedFP = resultFP.normalize(targetFP, true) // unsafe=true to allow truncation + + return normalizedFP.toString() + } catch { + return null + } +} + +/** + * Format percentage string with sign + */ +function formatPercentage(value: string | null): string { + if (value === null) return '' + + const isNegative = value.startsWith('-') + const isZero = value.replace(/[^1-9]/g, '') === '' + const absValue = isNegative ? value.slice(1) : value + + if (isZero) { + return `${absValue}%` + } else if (isNegative) { + return `-${absValue}%` + } else { + return `+${absValue}%` + } +} + +/** + * Display the difference between two Money values. + * + * @example + * // Basic usage + * + * // → "+$20.00" + * + * @example + * // With percentage + * + * // → "+$20.00 (+20.00%)" + * + * @example + * // Custom rendering + * + * {({ direction, formatted }) => ( + * + * {formatted.difference} + * + * )} + * + */ +export function MoneyDiff({ + value, + compareTo, + formatOptions, + showPercentage = false, + percentageDecimals = 2, + className, + style, + as: Component = 'span', + children, + ...rest +}: MoneyDiffProps & React.HTMLAttributes): ReactNode { + const renderProps = useMemo(() => { + const current = toMoney(value) + const compare = toMoney(compareTo) + const difference = current.subtract(compare) + + const percentageChange = calculatePercentageChange(current, compare, percentageDecimals) + + let direction: 'increase' | 'decrease' | 'unchanged' + if (difference.isPositive()) { + direction = 'increase' + } else if (difference.isNegative()) { + direction = 'decrease' + } else { + direction = 'unchanged' + } + + // Format the difference with sign + const absDiff = difference.absolute() + const diffFormatted = absDiff.toString(formatOptions) + const signedDiff = + direction === 'increase' + ? `+${diffFormatted}` + : direction === 'decrease' + ? `-${diffFormatted}` + : diffFormatted + + return { + current, + compareTo: compare, + difference, + percentageChange, + direction, + formatted: { + current: current.toString(formatOptions), + compareTo: compare.toString(formatOptions), + difference: signedDiff, + percentage: formatPercentage(percentageChange), + }, + } + }, [value, compareTo, formatOptions, percentageDecimals]) + + // Custom render + if (children) { + return ( + + {children(renderProps)} + + ) + } + + // Default render + const { formatted, direction } = renderProps + const displayText = showPercentage + ? `${formatted.difference} (${formatted.percentage})` + : formatted.difference + + return ( + + {displayText} + + ) +} diff --git a/packages/cent-react/src/components/MoneyDisplay.tsx b/packages/cent-react/src/components/MoneyDisplay.tsx new file mode 100644 index 0000000..13f960d --- /dev/null +++ b/packages/cent-react/src/components/MoneyDisplay.tsx @@ -0,0 +1,293 @@ +import { Money, MoneyClass } from '@thesis-co/cent' +import { type ReactNode, useMemo } from 'react' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +/** Options for formatting Money to string */ +interface MoneyFormatOptions { + locale?: string + compact?: boolean + maxDecimals?: number | bigint + minDecimals?: number | bigint + preferredUnit?: string + preferSymbol?: boolean + preferFractionalSymbol?: boolean + excludeCurrency?: boolean +} + +/** + * Parts of a formatted money value for custom rendering + */ +export interface MoneyParts { + /** The fully formatted string */ + formatted: string + /** Individual parts parsed from the formatted string (precision-safe) */ + parts: Array<{ type: string; value: string }> + /** Whether the value is negative */ + isNegative: boolean + /** Whether the value is zero */ + isZero: boolean + /** The original Money instance */ + money: MoneyInstance +} + +export interface MoneyDisplayProps { + /** The Money instance to display */ + value: MoneyInstance | string | null | undefined + + /** Display locale (default: "en-US") */ + locale?: string + + /** Use compact notation (e.g., $1M instead of $1,000,000) */ + compact?: boolean + + /** Maximum number of decimal places */ + maxDecimals?: number | bigint + + /** Minimum number of decimal places */ + minDecimals?: number | bigint + + /** Preferred fractional unit for crypto (e.g., "sat" for BTC) */ + preferredUnit?: string + + /** Use fractional unit symbol (e.g., "§10K" instead of "10K sats") */ + preferFractionalSymbol?: boolean + + /** Exclude currency symbol/code from output */ + excludeCurrency?: boolean + + /** Sign display mode */ + showSign?: 'always' | 'negative' | 'never' + + /** CSS class name */ + className?: string + + /** Inline styles */ + style?: React.CSSProperties + + /** Element type to render (default: "span") */ + as?: React.ElementType + + /** Content to show when value is null/undefined */ + placeholder?: ReactNode + + /** Custom render function for full control over rendering */ + children?: (parts: MoneyParts) => ReactNode +} + +/** + * Coerce a value to a Money instance + */ +function toMoney(value: MoneyInstance | string | null | undefined): MoneyInstance | null { + if (value == null) return null + if (typeof value === 'string') { + try { + return Money(value) as MoneyInstance + } catch { + return null + } + } + return value +} + +/** + * Parse a formatted money string into parts. + * This preserves full precision by parsing the string output from Money.toString() + * rather than converting to JavaScript Number. + */ +function parseFormattedParts(formatted: string): Array<{ type: string; value: string }> { + const parts: Array<{ type: string; value: string }> = [] + let remaining = formatted + let i = 0 + + while (i < remaining.length) { + const char = remaining[i] + + // Minus sign + if (char === '-') { + parts.push({ type: 'minusSign', value: '-' }) + i++ + continue + } + + // Plus sign + if (char === '+') { + parts.push({ type: 'plusSign', value: '+' }) + i++ + continue + } + + // Digits (collect consecutive digits as integer or fraction based on context) + if (/\d/.test(char)) { + let digits = '' + while (i < remaining.length && /\d/.test(remaining[i])) { + digits += remaining[i] + i++ + } + // Determine if this is integer or fraction based on whether we've seen a decimal + const hasDecimalBefore = parts.some(p => p.type === 'decimal') + parts.push({ type: hasDecimalBefore ? 'fraction' : 'integer', value: digits }) + continue + } + + // Decimal separator (period or comma depending on locale) + if (char === '.' || char === ',') { + // Check if this is a decimal or group separator + // If followed by exactly 3 digits and more content, likely group separator + // If followed by digits at end or non-digit, likely decimal + const afterChar = remaining.slice(i + 1) + const nextDigits = afterChar.match(/^(\d+)/) + + if (nextDigits && nextDigits[1].length === 3 && afterChar.length > 3 && /[\d,.]/.test(afterChar[3])) { + // Likely a group separator (thousands) + parts.push({ type: 'group', value: char }) + } else { + // Likely a decimal separator + parts.push({ type: 'decimal', value: char }) + } + i++ + continue + } + + // Whitespace + if (/\s/.test(char)) { + let ws = '' + while (i < remaining.length && /\s/.test(remaining[i])) { + ws += remaining[i] + i++ + } + parts.push({ type: 'literal', value: ws }) + continue + } + + // Currency symbols and other characters + // Collect consecutive non-digit, non-separator characters as currency + let other = '' + while (i < remaining.length && !/[\d.,\s+-]/.test(remaining[i])) { + other += remaining[i] + i++ + } + if (other) { + parts.push({ type: 'currency', value: other }) + } + } + + return parts +} + +/** + * Get formatted parts from a Money value. + * Uses Money.toString() for precision-safe formatting, then parses into parts. + */ +function getMoneyParts( + money: MoneyInstance, + options: MoneyFormatOptions, + showSign: 'always' | 'negative' | 'never' +): MoneyParts { + const formatted = money.toString(options) + const isNegative = money.isNegative() + const isZero = money.isZero() + + // Handle sign display + let finalFormatted = formatted + if (showSign === 'always' && !isNegative && !isZero) { + finalFormatted = `+${formatted}` + } else if (showSign === 'never' && isNegative) { + finalFormatted = formatted.replace(/^-/, '') + } + + // Parse the formatted string into parts (preserves full precision) + const parts = parseFormattedParts(finalFormatted) + + return { + formatted: finalFormatted, + parts, + isNegative, + isZero, + money, + } +} + +/** + * Display a Money value with formatting options. + * + * @example + * // Basic usage + * + * // → "$1,234.56" + * + * @example + * // Compact notation + * + * // → "$1.5M" + * + * @example + * // Custom parts rendering + * + * {({ parts }) => ( + * + * {parts.map((p, i) => ( + * {p.value} + * ))} + * + * )} + * + */ +export function MoneyDisplay({ + value, + locale, + compact, + maxDecimals, + minDecimals, + preferredUnit, + preferFractionalSymbol, + excludeCurrency, + showSign = 'negative', + className, + style, + as: Component = 'span', + placeholder, + children, + ...rest +}: MoneyDisplayProps & React.HTMLAttributes): ReactNode { + const money = useMemo(() => toMoney(value), [value]) + + const formatOptions: MoneyFormatOptions = useMemo( + () => ({ + locale, + compact, + maxDecimals, + minDecimals, + preferredUnit, + preferFractionalSymbol, + excludeCurrency, + }), + [locale, compact, maxDecimals, minDecimals, preferredUnit, preferFractionalSymbol, excludeCurrency] + ) + + const parts = useMemo(() => { + if (!money) return null + return getMoneyParts(money, formatOptions, showSign) + }, [money, formatOptions, showSign]) + + // Handle null/undefined value + if (!money || !parts) { + if (placeholder != null) { + return {placeholder} + } + return null + } + + // Custom render via children function + if (children) { + return {children(parts)} + } + + // Default render + return ( + + {parts.formatted} + + ) +} diff --git a/packages/cent-react/src/components/MoneyInput.tsx b/packages/cent-react/src/components/MoneyInput.tsx new file mode 100644 index 0000000..93a262e --- /dev/null +++ b/packages/cent-react/src/components/MoneyInput.tsx @@ -0,0 +1,286 @@ +import { Money, MoneyClass } from '@thesis-co/cent' +import { + type InputHTMLAttributes, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +/** + * Change event for MoneyInput, compatible with react-hook-form and formik + */ +export interface MoneyInputChangeEvent { + target: { + name: string + value: MoneyInstance | null + } +} + +/** + * Blur event for MoneyInput + */ +export interface MoneyInputBlurEvent { + target: { + name: string + value: MoneyInstance | null + } +} + +export interface MoneyInputProps + extends Omit< + InputHTMLAttributes, + 'value' | 'onChange' | 'onBlur' | 'type' | 'defaultValue' | 'min' | 'max' + > { + /** Current Money value (controlled) */ + value?: MoneyInstance | null + + /** Field name - required for form integration */ + name: string + + /** Currency for parsing input (required) */ + currency: string + + /** + * onChange handler - designed for form library compatibility + * + * For react-hook-form: + * } /> + * + * For formik: + * + */ + onChange?: (event: MoneyInputChangeEvent) => void + + /** Alternative: direct value handler */ + onValueChange?: (value: MoneyInstance | null) => void + + /** Blur handler */ + onBlur?: (event: MoneyInputBlurEvent) => void + + /** Minimum allowed value */ + min?: MoneyInstance | string + + /** Maximum allowed value */ + max?: MoneyInstance | string + + /** Format the display on blur (default: true) */ + formatOnBlur?: boolean + + /** Display locale for formatting */ + locale?: string + + /** Allow negative values (default: true) */ + allowNegative?: boolean + + /** Select all text on focus (default: true) */ + selectOnFocus?: boolean +} + +/** + * Parse a string input to Money, with fallback to currency + */ +function parseInput(input: string, currency: string, allowNegative: boolean): MoneyInstance | null { + if (!input.trim()) { + return null + } + + try { + // Try parsing with currency prefix/suffix + const result = MoneyClass.parse(input) + if (result.ok) { + const money = result.value + if (!allowNegative && money.isNegative()) { + return money.absolute() + } + return money + } + + // Try parsing as a plain number with the specified currency + const cleaned = input.replace(/[,\s]/g, '') + const numMatch = cleaned.match(/^-?[\d.]+$/) + if (numMatch) { + // Pass as string to preserve precision + const money = Money(`${cleaned} ${currency}`) as MoneyInstance + if (!allowNegative && money.isNegative()) { + return money.absolute() + } + return money + } + + return null + } catch { + return null + } +} + +/** + * Format Money for display in the input + */ +function formatForDisplay(money: MoneyInstance | null, locale?: string): string { + if (!money) return '' + return money.toString({ + locale, + excludeCurrency: true, + }) +} + +/** + * A controlled money input component compatible with react-hook-form and formik. + * + * @example + * // Basic usage + * const [amount, setAmount] = useState(null); + * setAmount(e.target.value)} + * currency="USD" + * /> + * + * @example + * // With react-hook-form + * ( + * + * )} + * /> + */ +export const MoneyInput = forwardRef(function MoneyInput( + { + value, + name, + currency, + onChange, + onValueChange, + onBlur, + min, + max, + formatOnBlur = true, + locale, + allowNegative = true, + selectOnFocus = true, + placeholder, + disabled, + className, + style, + ...rest + }, + ref +) { + const inputRef = useRef(null) + useImperativeHandle(ref, () => inputRef.current!) + + // Track whether we're currently editing + const [isEditing, setIsEditing] = useState(false) + const [displayValue, setDisplayValue] = useState(() => formatForDisplay(value ?? null, locale)) + + // Sync display value with controlled value when not editing + // Only sync if value is explicitly controlled (not undefined) + useEffect(() => { + if (!isEditing && value !== undefined) { + setDisplayValue(formatForDisplay(value, locale)) + } + }, [value, isEditing, locale]) + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const raw = e.target.value + setDisplayValue(raw) + + const parsed = parseInput(raw, currency, allowNegative) + + // Validate min/max + let validatedValue = parsed + if (validatedValue) { + try { + if (min) { + const minMoney = typeof min === 'string' ? (Money(min) as MoneyInstance) : min + if (validatedValue.lessThan(minMoney)) { + // Allow the input but mark as invalid (validation is external) + } + } + if (max) { + const maxMoney = typeof max === 'string' ? (Money(max) as MoneyInstance) : max + if (validatedValue.greaterThan(maxMoney)) { + // Allow the input but mark as invalid (validation is external) + } + } + } catch { + // Ignore validation errors for mismatched currencies + } + } + + if (onChange) { + onChange({ target: { name, value: validatedValue } }) + } + if (onValueChange) { + onValueChange(validatedValue) + } + }, + [currency, allowNegative, min, max, name, onChange, onValueChange] + ) + + const handleFocus = useCallback( + (_e: React.FocusEvent) => { + setIsEditing(true) + + // Show raw value for editing (without formatting) + if (value) { + const rawValue = value.toString({ excludeCurrency: true }) + // Remove thousand separators for easier editing + setDisplayValue(rawValue.replace(/,/g, '')) + } + + if (selectOnFocus) { + // Use setTimeout to ensure the value is set before selecting + setTimeout(() => { + inputRef.current?.select() + }, 0) + } + }, + [value, selectOnFocus] + ) + + const handleBlur = useCallback( + (_e: React.FocusEvent) => { + setIsEditing(false) + + // Format on blur if enabled + if (formatOnBlur && value) { + setDisplayValue(formatForDisplay(value, locale)) + } + + if (onBlur) { + onBlur({ target: { name, value: value ?? null } }) + } + }, + [formatOnBlur, value, locale, name, onBlur] + ) + + return ( + + ) +}) diff --git a/packages/cent-react/src/components/index.ts b/packages/cent-react/src/components/index.ts new file mode 100644 index 0000000..2d44c42 --- /dev/null +++ b/packages/cent-react/src/components/index.ts @@ -0,0 +1,8 @@ +export { MoneyDisplay } from './MoneyDisplay' +export type { MoneyDisplayProps, MoneyParts } from './MoneyDisplay' + +export { MoneyInput } from './MoneyInput' +export type { MoneyInputProps, MoneyInputChangeEvent, MoneyInputBlurEvent } from './MoneyInput' + +export { MoneyDiff } from './MoneyDiff' +export type { MoneyDiffProps, MoneyDiffRenderProps } from './MoneyDiff' diff --git a/packages/cent-react/src/context/MoneyProvider.tsx b/packages/cent-react/src/context/MoneyProvider.tsx new file mode 100644 index 0000000..aff34d7 --- /dev/null +++ b/packages/cent-react/src/context/MoneyProvider.tsx @@ -0,0 +1,120 @@ +import { type CentConfig, type ExchangeRate, getConfig } from '@thesis-co/cent' +import { type ReactNode, createContext, useContext, useMemo } from 'react' + +/** + * Function to resolve exchange rates + */ +export type ExchangeRateResolver = ( + from: string, + to: string +) => Promise | ExchangeRate | null + +/** + * Context value for MoneyProvider + */ +export interface MoneyContextValue { + /** Default locale for formatting */ + locale: string + + /** Default currency for inputs */ + defaultCurrency: string | null + + /** Exchange rate resolver */ + exchangeRateResolver: ExchangeRateResolver | null + + /** Cent library config */ + config: CentConfig +} + +/** + * Props for MoneyProvider + */ +export interface MoneyProviderProps { + children: ReactNode + + /** Default locale for all money formatting (default: "en-US") */ + locale?: string + + /** Default currency for inputs */ + defaultCurrency?: string + + /** Exchange rate resolver for conversions */ + exchangeRateResolver?: ExchangeRateResolver + + /** Cent library config overrides */ + config?: Partial +} + +const MoneyContext = createContext(null) + +/** + * Provider for default Money configuration. + * + * @example + * // Set defaults for all descendant components + * + * + * + * + * @example + * // With exchange rate resolver + * { + * const rate = await fetchExchangeRate(from, to) + * return new ExchangeRate(from, to, rate) + * }} + * > + * + * + */ +export function MoneyProvider({ + children, + locale = 'en-US', + defaultCurrency, + exchangeRateResolver, + config: configOverrides, +}: MoneyProviderProps): ReactNode { + const parentContext = useContext(MoneyContext) + + const contextValue = useMemo(() => { + // Get base config from Cent library + const baseConfig = getConfig() + + // Merge with overrides + const mergedConfig: CentConfig = configOverrides + ? { ...baseConfig, ...configOverrides } + : baseConfig + + return { + locale: locale ?? parentContext?.locale ?? 'en-US', + defaultCurrency: defaultCurrency ?? parentContext?.defaultCurrency ?? null, + exchangeRateResolver: exchangeRateResolver ?? parentContext?.exchangeRateResolver ?? null, + config: mergedConfig, + } + }, [locale, defaultCurrency, exchangeRateResolver, configOverrides, parentContext]) + + return {children} +} + +/** + * Default context value when no provider is present + */ +function getDefaultContextValue(): MoneyContextValue { + return { + locale: 'en-US', + defaultCurrency: null, + exchangeRateResolver: null, + config: getConfig(), + } +} + +/** + * Hook to access the Money context. + * + * @example + * const { locale, defaultCurrency } = useMoneyConfig() + */ +export function useMoneyContext(): MoneyContextValue { + const context = useContext(MoneyContext) + return context ?? getDefaultContextValue() +} diff --git a/packages/cent-react/src/context/index.ts b/packages/cent-react/src/context/index.ts new file mode 100644 index 0000000..423b1c8 --- /dev/null +++ b/packages/cent-react/src/context/index.ts @@ -0,0 +1,6 @@ +export { MoneyProvider, useMoneyContext } from './MoneyProvider' +export type { + MoneyProviderProps, + MoneyContextValue, + ExchangeRateResolver, +} from './MoneyProvider' diff --git a/packages/cent-react/src/hooks/index.ts b/packages/cent-react/src/hooks/index.ts new file mode 100644 index 0000000..d7cabaf --- /dev/null +++ b/packages/cent-react/src/hooks/index.ts @@ -0,0 +1,7 @@ +export { useMoney } from './useMoney' +export type { UseMoneyOptions, UseMoneyReturn } from './useMoney' + +export { useExchangeRate } from './useExchangeRate' +export type { UseExchangeRateOptions, UseExchangeRateReturn } from './useExchangeRate' + +export { useMoneyConfig } from './useMoneyConfig' diff --git a/packages/cent-react/src/hooks/useExchangeRate.ts b/packages/cent-react/src/hooks/useExchangeRate.ts new file mode 100644 index 0000000..de226a2 --- /dev/null +++ b/packages/cent-react/src/hooks/useExchangeRate.ts @@ -0,0 +1,198 @@ +import { type ExchangeRate, MoneyClass } from '@thesis-co/cent' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useMoneyContext } from '../context/MoneyProvider' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +export interface UseExchangeRateOptions { + /** Base currency code */ + from: string + + /** Quote currency code */ + to: string + + /** Auto-refresh interval in milliseconds (0 = disabled) */ + pollInterval?: number + + /** Time in ms after which rate is considered stale */ + staleThreshold?: number + + /** Whether to enable fetching (default: true) */ + enabled?: boolean +} + +export interface UseExchangeRateReturn { + /** Current exchange rate */ + rate: ExchangeRate | null + + /** Whether a fetch is in progress */ + isLoading: boolean + + /** Whether the rate is stale */ + isStale: boolean + + /** Error from the last fetch attempt */ + error: Error | null + + /** Time since last successful fetch in ms */ + age: number + + /** Convert a Money value using the current rate */ + convert: (money: MoneyInstance) => MoneyInstance | null + + /** Manually trigger a refetch */ + refetch: () => Promise +} + +/** + * Hook for fetching and managing exchange rates. + * + * Uses the exchangeRateResolver from MoneyProvider context. + * + * @example + * // Basic usage + * const { rate, convert, isLoading } = useExchangeRate({ + * from: 'USD', + * to: 'EUR' + * }) + * + * const eurAmount = convert(usdAmount) + * + * @example + * // With polling + * const { rate, isStale, refetch } = useExchangeRate({ + * from: 'BTC', + * to: 'USD', + * pollInterval: 30000, // 30 seconds + * staleThreshold: 60000 // 1 minute + * }) + */ +export function useExchangeRate(options: UseExchangeRateOptions): UseExchangeRateReturn { + const { from, to, pollInterval = 0, staleThreshold = 300000, enabled = true } = options + + const { exchangeRateResolver } = useMoneyContext() + + const [rate, setRate] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [lastFetchTime, setLastFetchTime] = useState(null) + const [age, setAge] = useState(0) + + const abortControllerRef = useRef(null) + + // Fetch the exchange rate + const fetchRate = useCallback(async () => { + if (!exchangeRateResolver) { + setError(new Error('No exchange rate resolver configured in MoneyProvider')) + return + } + + // Cancel any pending request + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + abortControllerRef.current = new AbortController() + + setIsLoading(true) + setError(null) + + try { + const result = await exchangeRateResolver(from, to) + setRate(result) + setLastFetchTime(Date.now()) + setError(null) + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { + // Ignore abort errors + return + } + setError(e instanceof Error ? e : new Error('Failed to fetch exchange rate')) + } finally { + setIsLoading(false) + } + }, [exchangeRateResolver, from, to]) + + // Initial fetch + useEffect(() => { + if (enabled) { + if (exchangeRateResolver) { + fetchRate() + } else { + // Set error when no resolver is configured + setError(new Error('No exchange rate resolver configured in MoneyProvider')) + } + } + + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + } + }, [enabled, exchangeRateResolver, from, to, fetchRate]) + + // Polling + useEffect(() => { + if (!enabled || pollInterval <= 0) { + return + } + + const intervalId = setInterval(fetchRate, pollInterval) + return () => clearInterval(intervalId) + }, [enabled, pollInterval, fetchRate]) + + // Update age + useEffect(() => { + if (!lastFetchTime) { + setAge(0) + return + } + + const updateAge = () => { + setAge(Date.now() - lastFetchTime) + } + + updateAge() + const intervalId = setInterval(updateAge, 1000) + return () => clearInterval(intervalId) + }, [lastFetchTime]) + + // Calculate staleness + const isStale = lastFetchTime ? age > staleThreshold : false + + // Convert function + const convert = useCallback( + (money: MoneyInstance): MoneyInstance | null => { + if (!rate) return null + + try { + // Use the ExchangeRate's convert method if available + if ('convert' in rate && typeof rate.convert === 'function') { + return rate.convert(money) as MoneyInstance + } + + // Fallback: manual conversion + // This assumes the rate is a simple numeric multiplier + if ('rate' in rate) { + const rateValue = rate.rate + return money.multiply(rateValue.toString()) as MoneyInstance + } + + return null + } catch { + return null + } + }, + [rate] + ) + + return { + rate, + isLoading, + isStale, + error, + age, + convert, + refetch: fetchRate, + } +} diff --git a/packages/cent-react/src/hooks/useMoney.ts b/packages/cent-react/src/hooks/useMoney.ts new file mode 100644 index 0000000..f5d4164 --- /dev/null +++ b/packages/cent-react/src/hooks/useMoney.ts @@ -0,0 +1,266 @@ +import { Money, MoneyClass } from '@thesis-co/cent' +import { useCallback, useMemo, useState } from 'react' + +/** Type alias for Money instance */ +type MoneyInstance = InstanceType + +/** Options for formatting Money to string */ +interface MoneyFormatOptions { + locale?: string + compact?: boolean + maxDecimals?: number | bigint + minDecimals?: number | bigint + preferredUnit?: string + preferSymbol?: boolean + preferFractionalSymbol?: boolean + excludeCurrency?: boolean +} + +export interface UseMoneyOptions { + /** Initial Money value */ + initialValue?: MoneyInstance | string | null + + /** Currency for parsing string inputs */ + currency?: string + + /** Minimum allowed value for validation */ + min?: MoneyInstance | string + + /** Maximum allowed value for validation */ + max?: MoneyInstance | string +} + +export interface UseMoneyReturn { + /** Current Money value */ + money: MoneyInstance | null + + /** Set Money value from various inputs */ + setMoney: (value: MoneyInstance | string | number | null) => void + + /** Format the current value */ + format: (options?: MoneyFormatOptions) => string + + /** Whether the current value is valid */ + isValid: boolean + + /** Current validation error, if any */ + error: Error | null + + /** Reset to initial value */ + reset: () => void + + /** Clear the value */ + clear: () => void + + /** + * Props to spread on a native input element + * @example + * const { inputProps } = useMoney({ currency: 'USD' }) + * + */ + inputProps: { + value: string + onChange: (e: React.ChangeEvent) => void + onBlur: () => void + } +} + +/** + * Parse a value to Money + */ +function parseMoney(value: MoneyInstance | string | number | null, currency?: string): MoneyInstance | null { + if (value == null) return null + + if (typeof value === 'object' && 'currency' in value) { + // Already a Money instance + return value as MoneyInstance + } + + if (typeof value === 'number') { + if (!currency) { + throw new Error('Currency is required when setting from a number') + } + // Convert number to string to preserve precision + return Money(`${value} ${currency}`) as MoneyInstance + } + + if (typeof value === 'string') { + if (!value.trim()) return null + + // Try parsing with embedded currency + const result = MoneyClass.parse(value) + if (result.ok) { + return result.value + } + + // Try parsing as number with provided currency + if (currency) { + const cleaned = value.replace(/[,\s]/g, '') + const numMatch = cleaned.match(/^-?[\d.]+$/) + if (numMatch) { + // Pass as string to preserve precision + return Money(`${cleaned} ${currency}`) as MoneyInstance + } + } + + return null + } + + return null +} + +/** + * Validate Money against constraints + */ +function validateMoney( + money: MoneyInstance | null, + min?: MoneyInstance | string, + max?: MoneyInstance | string +): Error | null { + if (!money) return null + + try { + if (min) { + const minMoney = typeof min === 'string' ? (Money(min) as MoneyInstance) : min + if (money.lessThan(minMoney)) { + return new Error(`Value must be at least ${minMoney.toString()}`) + } + } + + if (max) { + const maxMoney = typeof max === 'string' ? (Money(max) as MoneyInstance) : max + if (money.greaterThan(maxMoney)) { + return new Error(`Value must be at most ${maxMoney.toString()}`) + } + } + } catch (e) { + // Currency mismatch or other error + return e instanceof Error ? e : new Error('Validation error') + } + + return null +} + +/** + * Hook for managing Money state with validation. + * + * @example + * // Basic usage + * const { money, setMoney, format } = useMoney({ currency: 'USD' }) + * + * @example + * // With validation + * const { money, isValid, error } = useMoney({ + * currency: 'USD', + * min: '$0.01', + * max: '$1000' + * }) + * + * @example + * // With native input binding + * const { inputProps } = useMoney({ currency: 'USD' }) + * + */ +export function useMoney(options: UseMoneyOptions = {}): UseMoneyReturn { + const { initialValue, currency, min, max } = options + + const [money, setMoneyState] = useState(() => { + if (initialValue == null) return null + try { + return parseMoney(initialValue, currency) + } catch { + return null + } + }) + + const [displayValue, setDisplayValue] = useState(() => { + return money?.toString({ excludeCurrency: true }) ?? '' + }) + + // Validation + const error = useMemo(() => validateMoney(money, min, max), [money, min, max]) + const isValid = error === null + + // Set money from various inputs + const setMoney = useCallback( + (value: MoneyInstance | string | number | null) => { + try { + const parsed = parseMoney(value, currency) + setMoneyState(parsed) + setDisplayValue(parsed?.toString({ excludeCurrency: true }) ?? '') + } catch { + setMoneyState(null) + setDisplayValue('') + } + }, + [currency] + ) + + // Format the current value + const format = useCallback( + (formatOptions?: MoneyFormatOptions) => { + if (!money) return '' + return money.toString(formatOptions) + }, + [money] + ) + + // Reset to initial value + const reset = useCallback(() => { + try { + const initial = initialValue != null ? parseMoney(initialValue, currency) : null + setMoneyState(initial) + setDisplayValue(initial?.toString({ excludeCurrency: true }) ?? '') + } catch { + setMoneyState(null) + setDisplayValue('') + } + }, [initialValue, currency]) + + // Clear the value + const clear = useCallback(() => { + setMoneyState(null) + setDisplayValue('') + }, []) + + // Input props for native input binding + const inputProps = useMemo( + () => ({ + value: displayValue, + onChange: (e: React.ChangeEvent) => { + const raw = e.target.value + setDisplayValue(raw) + + if (!raw.trim()) { + setMoneyState(null) + return + } + + try { + const parsed = parseMoney(raw, currency) + setMoneyState(parsed) + } catch { + // Keep display value but don't update money + } + }, + onBlur: () => { + // Format on blur + if (money) { + setDisplayValue(money.toString({ excludeCurrency: true })) + } + }, + }), + [displayValue, currency, money] + ) + + return { + money, + setMoney, + format, + isValid, + error, + reset, + clear, + inputProps, + } +} diff --git a/packages/cent-react/src/hooks/useMoneyConfig.ts b/packages/cent-react/src/hooks/useMoneyConfig.ts new file mode 100644 index 0000000..864bd3e --- /dev/null +++ b/packages/cent-react/src/hooks/useMoneyConfig.ts @@ -0,0 +1,2 @@ +// Re-export from context for convenience +export { useMoneyContext as useMoneyConfig } from '../context/MoneyProvider' diff --git a/packages/cent-react/src/index.ts b/packages/cent-react/src/index.ts new file mode 100644 index 0000000..ec012f2 --- /dev/null +++ b/packages/cent-react/src/index.ts @@ -0,0 +1,22 @@ +// Components +export { MoneyDisplay } from './components/MoneyDisplay' +export type { MoneyDisplayProps, MoneyParts } from './components/MoneyDisplay' + +export { MoneyInput } from './components/MoneyInput' +export type { MoneyInputProps, MoneyInputChangeEvent } from './components/MoneyInput' + +export { MoneyDiff } from './components/MoneyDiff' +export type { MoneyDiffProps, MoneyDiffRenderProps } from './components/MoneyDiff' + +// Hooks +export { useMoney } from './hooks/useMoney' +export type { UseMoneyOptions, UseMoneyReturn } from './hooks/useMoney' + +export { useExchangeRate } from './hooks/useExchangeRate' +export type { UseExchangeRateOptions, UseExchangeRateReturn } from './hooks/useExchangeRate' + +export { useMoneyConfig } from './hooks/useMoneyConfig' + +// Context +export { MoneyProvider } from './context/MoneyProvider' +export type { MoneyProviderProps, MoneyContextValue } from './context/MoneyProvider' From 98d4fc968b57a49fcede1937cb9985c435c79b91 Mon Sep 17 00:00:00 2001 From: Matt Luongo Date: Mon, 12 Jan 2026 15:20:08 -0500 Subject: [PATCH 3/3] Add tests for cent-react components and hooks --- .../test/components/MoneyDiff.test.tsx | 123 +++++++++++ .../test/components/MoneyDisplay.test.tsx | 120 +++++++++++ .../test/components/MoneyInput.test.tsx | 141 ++++++++++++ .../test/context/MoneyProvider.test.tsx | 125 +++++++++++ .../test/hooks/useExchangeRate.test.tsx | 113 ++++++++++ .../cent-react/test/hooks/useMoney.test.ts | 204 ++++++++++++++++++ packages/cent-react/test/setup.ts | 9 + 7 files changed, 835 insertions(+) create mode 100644 packages/cent-react/test/components/MoneyDiff.test.tsx create mode 100644 packages/cent-react/test/components/MoneyDisplay.test.tsx create mode 100644 packages/cent-react/test/components/MoneyInput.test.tsx create mode 100644 packages/cent-react/test/context/MoneyProvider.test.tsx create mode 100644 packages/cent-react/test/hooks/useExchangeRate.test.tsx create mode 100644 packages/cent-react/test/hooks/useMoney.test.ts create mode 100644 packages/cent-react/test/setup.ts diff --git a/packages/cent-react/test/components/MoneyDiff.test.tsx b/packages/cent-react/test/components/MoneyDiff.test.tsx new file mode 100644 index 0000000..dce612b --- /dev/null +++ b/packages/cent-react/test/components/MoneyDiff.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react' +import { Money } from '@thesis-co/cent' +import { MoneyDiff } from '../../src/components/MoneyDiff' + +describe('MoneyDiff', () => { + describe('basic rendering', () => { + it('renders positive difference with plus sign', () => { + render() + expect(screen.getByText('+$20.00')).toBeInTheDocument() + }) + + it('renders negative difference with minus sign', () => { + render() + expect(screen.getByText('-$20.00')).toBeInTheDocument() + }) + + it('renders zero difference without sign', () => { + render() + expect(screen.getByText('$0.00')).toBeInTheDocument() + }) + + it('accepts string values', () => { + render() + expect(screen.getByText('+$50.00')).toBeInTheDocument() + }) + }) + + describe('percentage display', () => { + it('shows percentage when showPercentage is true', () => { + render() + expect(screen.getByText('+$20.00 (+20.00%)')).toBeInTheDocument() + }) + + it('shows negative percentage for decrease', () => { + render() + expect(screen.getByText('-$20.00 (-20.00%)')).toBeInTheDocument() + }) + + it('respects percentageDecimals option', () => { + render( + + ) + expect(screen.getByText(/33\.3%/)).toBeInTheDocument() + }) + }) + + describe('data-direction attribute', () => { + it('sets data-direction to increase for positive diff', () => { + render() + expect(screen.getByTestId('diff')).toHaveAttribute('data-direction', 'increase') + }) + + it('sets data-direction to decrease for negative diff', () => { + render() + expect(screen.getByTestId('diff')).toHaveAttribute('data-direction', 'decrease') + }) + + it('sets data-direction to unchanged for zero diff', () => { + render() + expect(screen.getByTestId('diff')).toHaveAttribute('data-direction', 'unchanged') + }) + }) + + describe('custom rendering', () => { + it('passes render props to children function', () => { + render( + + {({ direction, formatted, percentageChange }) => ( + + {direction}: {formatted.difference} ({percentageChange}%) + + )} + + ) + + expect(screen.getByTestId('custom')).toHaveTextContent('increase: +$50.00 (50.00%)') + }) + + it('provides Money instances in render props', () => { + let receivedCurrent: Money | null = null + let receivedDiff: Money | null = null + + render( + + {({ current, difference }) => { + receivedCurrent = current + receivedDiff = difference + return test + }} + + ) + + expect(receivedCurrent?.toString()).toBe('$120.00') + expect(receivedDiff?.toString()).toBe('$20.00') + }) + }) + + describe('styling', () => { + it('applies className', () => { + render( + + ) + expect(screen.getByTestId('diff')).toHaveClass('my-diff') + }) + + it('renders as different element type', () => { + render( + + ) + expect(screen.getByTestId('diff').tagName).toBe('DIV') + }) + }) +}) diff --git a/packages/cent-react/test/components/MoneyDisplay.test.tsx b/packages/cent-react/test/components/MoneyDisplay.test.tsx new file mode 100644 index 0000000..cbacc31 --- /dev/null +++ b/packages/cent-react/test/components/MoneyDisplay.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from '@testing-library/react' +import { Money } from '@thesis-co/cent' +import { MoneyDisplay } from '../../src/components/MoneyDisplay' + +describe('MoneyDisplay', () => { + describe('basic rendering', () => { + it('renders formatted money value', () => { + render() + expect(screen.getByText('$100.50')).toBeInTheDocument() + }) + + it('renders with string value', () => { + render() + expect(screen.getByText('$200.00')).toBeInTheDocument() + }) + + it('renders nothing for null value without placeholder', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('renders placeholder for null value', () => { + render() + expect(screen.getByText('—')).toBeInTheDocument() + }) + + it('renders placeholder for undefined value', () => { + render() + expect(screen.getByText('N/A')).toBeInTheDocument() + }) + }) + + describe('formatting options', () => { + it('applies compact notation', () => { + render() + // Compact notation varies by browser, just check it renders + expect(screen.getByText(/\$1\.5M|\$1,500K/)).toBeInTheDocument() + }) + + it('applies maxDecimals', () => { + render() + expect(screen.getByText('$101.00')).toBeInTheDocument() + }) + + it('excludes currency when requested', () => { + render() + expect(screen.getByText('100.00')).toBeInTheDocument() + }) + }) + + describe('showSign prop', () => { + it('shows negative sign by default for negative values', () => { + render() + expect(screen.getByText(/-\$50\.00|-50\.00/)).toBeInTheDocument() + }) + + it('shows positive sign when showSign is always', () => { + render() + expect(screen.getByText(/\+\$50\.00/)).toBeInTheDocument() + }) + + it('hides sign when showSign is never', () => { + render() + expect(screen.getByText('$50.00')).toBeInTheDocument() + }) + }) + + describe('custom rendering', () => { + it('passes parts to children function', () => { + render( + + {({ formatted, isNegative, isZero }) => ( + + {formatted} - neg:{String(isNegative)} - zero:{String(isZero)} + + )} + + ) + + const element = screen.getByTestId('custom') + expect(element).toHaveTextContent('$99.99') + expect(element).toHaveTextContent('neg:false') + expect(element).toHaveTextContent('zero:false') + }) + + it('provides money instance in parts', () => { + let receivedMoney: Money | null = null + + render( + + {({ money }) => { + receivedMoney = money + return test + }} + + ) + + expect(receivedMoney).not.toBeNull() + expect(receivedMoney!.toString()).toBe('$100.00') + }) + }) + + describe('styling', () => { + it('applies className', () => { + render() + expect(screen.getByText('$100.00')).toHaveClass('my-class') + }) + + it('applies style', () => { + render() + expect(screen.getByText('$100.00')).toHaveStyle({ color: 'red' }) + }) + + it('renders as different element type', () => { + render() + const element = screen.getByTestId('money') + expect(element.tagName).toBe('DIV') + }) + }) +}) diff --git a/packages/cent-react/test/components/MoneyInput.test.tsx b/packages/cent-react/test/components/MoneyInput.test.tsx new file mode 100644 index 0000000..3d8e5e6 --- /dev/null +++ b/packages/cent-react/test/components/MoneyInput.test.tsx @@ -0,0 +1,141 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Money } from '@thesis-co/cent' +import { MoneyInput } from '../../src/components/MoneyInput' + +describe('MoneyInput', () => { + describe('basic rendering', () => { + it('renders an input element', () => { + render() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('displays controlled value', () => { + render() + expect(screen.getByRole('textbox')).toHaveValue('100.00') + }) + + it('displays placeholder', () => { + render() + expect(screen.getByPlaceholderText('Enter amount')).toBeInTheDocument() + }) + }) + + describe('user input', () => { + it('calls onChange with parsed Money value', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.type(input, '50.00') + + expect(onChange).toHaveBeenCalled() + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] + expect(lastCall.target.name).toBe('amount') + expect(lastCall.target.value).not.toBeNull() + }) + + it('calls onValueChange with Money value', async () => { + const user = userEvent.setup() + const onValueChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.type(input, '75') + + expect(onValueChange).toHaveBeenCalled() + }) + + it('handles clearing input', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.clear(input) + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] + expect(lastCall.target.value).toBeNull() + }) + }) + + describe('formatting', () => { + it('formats value on blur when formatOnBlur is true with controlled value', () => { + const value = Money('$1234.50') + render( + + ) + + const input = screen.getByRole('textbox') + // Controlled value should display formatted (without currency since excludeCurrency) + expect(input).toHaveValue('1,234.50') + }) + + it('displays unformatted value when controlled value is set', () => { + const value = Money('$100') + render( + + ) + + const input = screen.getByRole('textbox') + expect(input).toHaveValue('100.00') + }) + }) + + describe('validation', () => { + it('allows negative values by default', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.type(input, '-50') + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] + expect(lastCall.target.value?.isNegative()).toBe(true) + }) + + it('converts negative to positive when allowNegative is false', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + + render() + + const input = screen.getByRole('textbox') + await user.type(input, '-50') + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] + expect(lastCall.target.value?.isNegative()).toBe(false) + }) + }) + + describe('props passthrough', () => { + it('passes disabled prop', () => { + render() + expect(screen.getByRole('textbox')).toBeDisabled() + }) + + it('passes className', () => { + render() + expect(screen.getByRole('textbox')).toHaveClass('my-input') + }) + + it('sets inputMode to decimal', () => { + render() + expect(screen.getByRole('textbox')).toHaveAttribute('inputMode', 'decimal') + }) + }) + + describe('ref forwarding', () => { + it('forwards ref to input element', () => { + const ref = { current: null as HTMLInputElement | null } + render() + expect(ref.current).toBeInstanceOf(HTMLInputElement) + }) + }) +}) diff --git a/packages/cent-react/test/context/MoneyProvider.test.tsx b/packages/cent-react/test/context/MoneyProvider.test.tsx new file mode 100644 index 0000000..f1e3511 --- /dev/null +++ b/packages/cent-react/test/context/MoneyProvider.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from '@testing-library/react' +import { MoneyProvider, useMoneyContext } from '../../src/context/MoneyProvider' + +// Test component that displays context values +function ContextDisplay() { + const { locale, defaultCurrency } = useMoneyContext() + return ( +
+ {locale} + {defaultCurrency ?? 'none'} +
+ ) +} + +describe('MoneyProvider', () => { + describe('default values', () => { + it('provides default locale without provider', () => { + render() + expect(screen.getByTestId('locale')).toHaveTextContent('en-US') + }) + + it('provides null defaultCurrency without provider', () => { + render() + expect(screen.getByTestId('currency')).toHaveTextContent('none') + }) + }) + + describe('with provider', () => { + it('provides custom locale', () => { + render( + + + + ) + expect(screen.getByTestId('locale')).toHaveTextContent('de-DE') + }) + + it('provides custom defaultCurrency', () => { + render( + + + + ) + expect(screen.getByTestId('currency')).toHaveTextContent('EUR') + }) + + it('provides multiple values', () => { + render( + + + + ) + expect(screen.getByTestId('locale')).toHaveTextContent('fr-FR') + expect(screen.getByTestId('currency')).toHaveTextContent('CHF') + }) + }) + + describe('nesting', () => { + it('nested provider overrides parent values', () => { + render( + + + + + + ) + expect(screen.getByTestId('locale')).toHaveTextContent('de-DE') + expect(screen.getByTestId('currency')).toHaveTextContent('EUR') + }) + + it('nested provider inherits unspecified values from parent', () => { + render( + + + + + + ) + expect(screen.getByTestId('locale')).toHaveTextContent('de-DE') + expect(screen.getByTestId('currency')).toHaveTextContent('GBP') + }) + }) + + describe('exchangeRateResolver', () => { + it('provides resolver to context', () => { + const mockResolver = jest.fn() + let receivedResolver: typeof mockResolver | null = null + + function ResolverCapture() { + const { exchangeRateResolver } = useMoneyContext() + receivedResolver = exchangeRateResolver + return null + } + + render( + + + + ) + + expect(receivedResolver).toBe(mockResolver) + }) + + it('inherits resolver from parent when not specified', () => { + const mockResolver = jest.fn() + let receivedResolver: typeof mockResolver | null = null + + function ResolverCapture() { + const { exchangeRateResolver } = useMoneyContext() + receivedResolver = exchangeRateResolver + return null + } + + render( + + + + + + ) + + expect(receivedResolver).toBe(mockResolver) + }) + }) +}) diff --git a/packages/cent-react/test/hooks/useExchangeRate.test.tsx b/packages/cent-react/test/hooks/useExchangeRate.test.tsx new file mode 100644 index 0000000..a8bddd7 --- /dev/null +++ b/packages/cent-react/test/hooks/useExchangeRate.test.tsx @@ -0,0 +1,113 @@ +import { renderHook, act, waitFor } from '@testing-library/react' +import { ExchangeRate, Money } from '@thesis-co/cent' +import type { ReactNode } from 'react' +import { MoneyProvider } from '../../src/context/MoneyProvider' +import { useExchangeRate } from '../../src/hooks/useExchangeRate' + +describe('useExchangeRate', () => { + const mockResolver = jest.fn() + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('initial state', () => { + it('starts with null rate', () => { + mockResolver.mockResolvedValue(null) + + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false }), + { wrapper } + ) + + expect(result.current.rate).toBeNull() + expect(result.current.error).toBeNull() + }) + + it('returns error when no resolver configured', () => { + const { result } = renderHook(() => useExchangeRate({ from: 'USD', to: 'EUR' })) + + // Should have error since no provider + expect(result.current.error).not.toBeNull() + expect(result.current.error?.message).toContain('No exchange rate resolver') + }) + }) + + describe('fetching', () => { + it('does not fetch when enabled is false', () => { + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false }), + { wrapper } + ) + + expect(mockResolver).not.toHaveBeenCalled() + expect(result.current.rate).toBeNull() + }) + + it('calls resolver with correct currencies', async () => { + mockResolver.mockResolvedValue(null) + + renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR' }), + { wrapper } + ) + + await waitFor(() => { + expect(mockResolver).toHaveBeenCalledWith('USD', 'EUR') + }) + }) + }) + + describe('staleness', () => { + it('isStale is false initially when no rate fetched', () => { + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false, staleThreshold: 1000 }), + { wrapper } + ) + + expect(result.current.isStale).toBe(false) + }) + }) + + describe('convert', () => { + it('returns null when no rate available', () => { + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false }), + { wrapper } + ) + + const usd = Money('$100') + expect(result.current.convert(usd)).toBeNull() + }) + }) + + describe('options', () => { + it('respects enabled option', () => { + const { result, rerender } = renderHook( + ({ enabled }) => useExchangeRate({ from: 'USD', to: 'EUR', enabled }), + { wrapper, initialProps: { enabled: false } } + ) + + expect(mockResolver).not.toHaveBeenCalled() + + // Enable and check if it fetches + rerender({ enabled: true }) + + // Just verify the hook doesn't crash + expect(result.current.rate).toBeNull() + }) + + it('provides refetch function', () => { + const { result } = renderHook( + () => useExchangeRate({ from: 'USD', to: 'EUR', enabled: false }), + { wrapper } + ) + + expect(typeof result.current.refetch).toBe('function') + }) + }) +}) diff --git a/packages/cent-react/test/hooks/useMoney.test.ts b/packages/cent-react/test/hooks/useMoney.test.ts new file mode 100644 index 0000000..1f77896 --- /dev/null +++ b/packages/cent-react/test/hooks/useMoney.test.ts @@ -0,0 +1,204 @@ +import { renderHook, act } from '@testing-library/react' +import { Money } from '@thesis-co/cent' +import { useMoney } from '../../src/hooks/useMoney' + +describe('useMoney', () => { + describe('initialization', () => { + it('initializes with null when no initialValue provided', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + expect(result.current.money).toBeNull() + }) + + it('initializes with Money from string initialValue', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100.00', currency: 'USD' }) + ) + expect(result.current.money).not.toBeNull() + expect(result.current.money?.toString()).toBe('$100.00') + }) + + it('initializes with Money instance', () => { + const initial = Money('$50.00') + const { result } = renderHook(() => useMoney({ initialValue: initial })) + expect(result.current.money?.toString()).toBe('$50.00') + }) + }) + + describe('setMoney', () => { + it('sets money from string', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + act(() => { + result.current.setMoney('$200.00') + }) + + expect(result.current.money?.toString()).toBe('$200.00') + }) + + it('sets money from string with currency', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + act(() => { + result.current.setMoney('150') + }) + + expect(result.current.money?.toString()).toBe('$150.00') + }) + + it('sets money to null', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100', currency: 'USD' }) + ) + + act(() => { + result.current.setMoney(null) + }) + + expect(result.current.money).toBeNull() + }) + + it('sets money from Money instance', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + act(() => { + result.current.setMoney(Money('€50.00')) + }) + + expect(result.current.money?.toString()).toBe('€50.00') + }) + }) + + describe('validation', () => { + it('returns isValid true when no constraints violated', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$50', currency: 'USD', min: '$10', max: '$100' }) + ) + + expect(result.current.isValid).toBe(true) + expect(result.current.error).toBeNull() + }) + + it('returns error when value below min', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$5', currency: 'USD', min: '$10' }) + ) + + expect(result.current.isValid).toBe(false) + expect(result.current.error).not.toBeNull() + expect(result.current.error?.message).toContain('at least') + }) + + it('returns error when value above max', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$150', currency: 'USD', max: '$100' }) + ) + + expect(result.current.isValid).toBe(false) + expect(result.current.error).not.toBeNull() + expect(result.current.error?.message).toContain('at most') + }) + + it('isValid is true for null value', () => { + const { result } = renderHook(() => + useMoney({ currency: 'USD', min: '$10' }) + ) + + expect(result.current.money).toBeNull() + expect(result.current.isValid).toBe(true) + }) + }) + + describe('format', () => { + it('formats money with default options', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$1234.56', currency: 'USD' }) + ) + + expect(result.current.format()).toBe('$1,234.56') + }) + + it('formats with custom options', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$1500000', currency: 'USD' }) + ) + + expect(result.current.format({ compact: true })).toMatch(/\$1\.5M|\$1,500K/) + }) + + it('returns empty string for null money', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + expect(result.current.format()).toBe('') + }) + }) + + describe('reset and clear', () => { + it('reset returns to initial value', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100', currency: 'USD' }) + ) + + act(() => { + result.current.setMoney('$500') + }) + expect(result.current.money?.toString()).toBe('$500.00') + + act(() => { + result.current.reset() + }) + expect(result.current.money?.toString()).toBe('$100.00') + }) + + it('clear sets money to null', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100', currency: 'USD' }) + ) + + act(() => { + result.current.clear() + }) + expect(result.current.money).toBeNull() + }) + }) + + describe('inputProps', () => { + it('provides value as string', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$100', currency: 'USD' }) + ) + + expect(result.current.inputProps.value).toBe('100.00') + }) + + it('provides empty string for null money', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + expect(result.current.inputProps.value).toBe('') + }) + + it('onChange updates money', () => { + const { result } = renderHook(() => useMoney({ currency: 'USD' })) + + act(() => { + result.current.inputProps.onChange({ + target: { value: '75.50' }, + } as React.ChangeEvent) + }) + + expect(result.current.money?.toString()).toBe('$75.50') + }) + + it('onBlur formats the display value', () => { + const { result } = renderHook(() => + useMoney({ initialValue: '$1234.5', currency: 'USD' }) + ) + + act(() => { + result.current.inputProps.onBlur() + }) + + // After blur, should be formatted with proper decimals + expect(result.current.inputProps.value).toBe('1,234.50') + }) + }) +}) diff --git a/packages/cent-react/test/setup.ts b/packages/cent-react/test/setup.ts new file mode 100644 index 0000000..84b6513 --- /dev/null +++ b/packages/cent-react/test/setup.ts @@ -0,0 +1,9 @@ +import '@testing-library/jest-dom' +import { configure } from '@thesis-co/cent' + +// Configure Cent with strict settings to catch any precision issues in tests +configure({ + numberInputMode: 'never', // Disallow Number inputs entirely + defaultRoundingMode: 'none', // No implicit rounding + strictPrecision: true, // Throw on any precision loss +})