diff --git a/package.json b/package.json index a938191..8d4a21c 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,12 @@ "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a", "devDependencies": { "@biomejs/biome": "2.3.10", + "@testing-library/react": "^16.3.2", "@total-typescript/tsconfig": "^1.0.4", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.2", + "jsdom": "^27.4.0", "react": "^19.2.3", "react-dom": "^19.2.3", "tsup": "^8.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cbc202..742ba3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@biomejs/biome': specifier: 2.3.10 version: 2.3.10 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@total-typescript/tsconfig': specifier: ^1.0.4 version: 1.0.4 @@ -23,6 +26,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.2 version: 5.1.2(vite@7.3.0(@types/node@24.7.2)(terser@5.44.0)) + jsdom: + specifier: ^27.4.0 + version: 27.4.0 react: specifier: ^19.2.3 version: 19.2.3 @@ -40,10 +46,22 @@ importers: version: 7.3.0(@types/node@24.7.2)(terser@5.44.0) vitest: specifier: ^4.0.14 - version: 4.0.14(@types/node@24.7.2)(terser@5.44.0) + version: 4.0.14(@types/node@24.7.2)(jsdom@27.4.0)(terser@5.44.0) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -115,6 +133,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -180,6 +202,38 @@ packages: cpu: [x64] os: [win32] + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.25': + resolution: {integrity: sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -336,6 +390,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.9.0': + resolution: {integrity: sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -471,9 +534,31 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@total-typescript/tsconfig@1.0.4': resolution: {integrity: sha512-fO4ctMPGz1kOFOQ4RCPBRBfMy3gDn+pegUfrGyUFRMv/Rd0ZM3/SHH3hFCYG4u6bPLG8OlmOGcBLDexvyr3A5w==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -546,9 +631,24 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -557,6 +657,9 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -603,9 +706,21 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -615,9 +730,23 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -658,6 +787,21 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -665,6 +809,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -686,12 +839,23 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + 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 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -716,6 +880,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -755,11 +922,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + 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} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: react: ^19.2.3 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -772,6 +950,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -781,6 +963,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -817,6 +1003,9 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + terser@5.44.0: resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} engines: {node: '>=10'} @@ -843,6 +1032,21 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -960,16 +1164,75 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + 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@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} snapshots: + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@4.1.1': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1059,6 +1322,8 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.6': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -1117,6 +1382,28 @@ snapshots: '@biomejs/cli-win32-x64@2.3.10': optional: true + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.25': {} + + '@csstools/css-tokenizer@3.0.4': {} + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -1195,6 +1482,8 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true + '@exodus/bytes@1.9.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1290,8 +1579,31 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@total-typescript/tsconfig@1.0.4': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -1388,12 +1700,26 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + any-promise@1.3.0: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + assertion-error@2.0.1: {} baseline-browser-mapping@2.9.11: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.11 @@ -1431,14 +1757,39 @@ snapshots: convert-source-map@2.0.0: {} + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.25 + css-tree: 3.1.0 + lru-cache: 11.2.4 + csstype@3.1.3: {} + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + electron-to-chromium@1.5.267: {} + entities@6.0.1: {} + es-module-lexer@1.7.0: {} esbuild@0.27.2: @@ -1493,10 +1844,60 @@ snapshots: gensync@1.0.0-beta.2: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.9.0 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + is-potential-custom-element-name@1.0.1: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} + jsdom@27.4.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.7.6 + '@exodus/bytes': 1.9.0 + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json5@2.2.3: {} @@ -1507,14 +1908,20 @@ snapshots: load-tsconfig@0.2.5: {} + lru-cache@11.2.4: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mdn-data@2.12.2: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -1538,6 +1945,10 @@ snapshots: obug@2.1.1: {} + parse5@8.0.0: + dependencies: + entities: 6.0.1 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -1564,17 +1975,29 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.18.0: {} react@19.2.3: {} readdirp@4.1.2: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} rollup@4.53.3: @@ -1605,6 +2028,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -1638,6 +2065,8 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + symbol-tree@3.2.4: {} + terser@5.44.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -1665,6 +2094,20 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -1723,7 +2166,7 @@ snapshots: fsevents: 2.3.3 terser: 5.44.0 - vitest@4.0.14(@types/node@24.7.2)(terser@5.44.0): + vitest@4.0.14(@types/node@24.7.2)(jsdom@27.4.0)(terser@5.44.0): dependencies: '@vitest/expect': 4.0.14 '@vitest/mocker': 4.0.14(vite@7.3.0(@types/node@24.7.2)(terser@5.44.0)) @@ -1747,6 +2190,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.7.2 + jsdom: 27.4.0 transitivePeerDependencies: - jiti - less @@ -1760,9 +2204,30 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..f924edc --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,22 @@ +import { vi } from "vitest"; + +// Mock ResizeObserver which is not available in jsdom +class ResizeObserverMock { + private callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(_target: Element) { + // Immediately call callback with empty entries to simulate initial observation + // In real tests, you can trigger resize by calling the callback manually + } + + unobserve(_target: Element) {} + + disconnect() {} +} + +// @ts-expect-error - Mock ResizeObserver for jsdom environment +globalThis.ResizeObserver = ResizeObserverMock; diff --git a/src/useDockLayout.test.tsx b/src/useDockLayout.test.tsx new file mode 100644 index 0000000..e8d24f3 --- /dev/null +++ b/src/useDockLayout.test.tsx @@ -0,0 +1,610 @@ +import { act, render } from "@testing-library/react"; +import type { RefCallback } from "react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { LayoutNode, PanelLayoutRect, SplitLayoutRect } from "./types"; +import { useDockLayout } from "./useDockLayout"; + +/** + * Helper to render the hook with a real DOM container element. + * This is needed because useDockLayout uses useResizeObserver which + * requires the containerRef to be attached to a real element. + * + * IMPORTANT: Do not destructure `result` from the returned object. + * Always access it via `hookResult.result.current` to get the latest value + * after state updates. + */ +function renderDockLayoutHook( + initialRoot: LayoutNode | null | (() => LayoutNode | null), + options?: Parameters[1], +) { + const resultHolder: { + current: ReturnType> | null; + } = { current: null }; + + function TestComponent() { + const result = useDockLayout(initialRoot, options); + // Update the holder on every render so we always have the latest result + resultHolder.current = result; + + return ( +
} + data-testid="container" + style={{ width: 500, height: 500 }} + /> + ); + } + + const renderResult = render(); + + return { + /** + * Getter that returns the current hook result. + * Call this each time you need to access the result to get the latest value. + */ + get result() { + return { current: resultHolder.current! }; + }, + ...renderResult, + }; +} + +describe("useDockLayout", () => { + describe("initialization", () => { + it("should initialize with null root", () => { + const hookResult = renderDockLayoutHook(null); + + expect(hookResult.result.current.root).toBe(null); + expect(hookResult.result.current.layoutRects).toEqual([]); + }); + + it("should initialize with a panel node", () => { + const initialRoot: LayoutNode = { + id: "panel-1", + type: "panel", + }; + + const hookResult = renderDockLayoutHook(initialRoot); + + expect(hookResult.result.current.root).toEqual(initialRoot); + }); + + it("should initialize with a lazy initializer function", () => { + const initialRoot: LayoutNode = { + id: "panel-1", + type: "panel", + }; + const initializer = vi.fn(() => initialRoot); + + const hookResult = renderDockLayoutHook(initializer); + + expect(initializer).toHaveBeenCalledTimes(1); + expect(hookResult.result.current.root).toEqual(initialRoot); + }); + + it("should initialize with options", () => { + const initialRoot: LayoutNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + + const hookResult = renderDockLayoutHook(initialRoot, { gap: 20 }); + + expect(hookResult.result.current.root).toEqual(initialRoot); + }); + }); + + describe("addPanel", () => { + it("should add a panel to null root", () => { + const hookResult = renderDockLayoutHook(null); + + act(() => { + hookResult.result.current.addPanel("new-panel"); + }); + + expect(hookResult.result.current.root).toEqual({ + id: "new-panel", + type: "panel", + }); + }); + + it("should add a panel to existing panel root", () => { + const initialRoot: LayoutNode = { + id: "panel-1", + type: "panel", + }; + + const hookResult = renderDockLayoutHook(initialRoot); + + act(() => { + hookResult.result.current.addPanel("panel-2"); + }); + + expect(hookResult.result.current.root).toMatchObject({ + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { id: "panel-1", type: "panel" }, + right: { id: "panel-2", type: "panel" }, + }); + }); + + it("should add multiple panels with equal width ratio strategy", () => { + const hookResult = renderDockLayoutHook(null); + + act(() => { + hookResult.result.current.addPanel("panel-1"); + }); + act(() => { + hookResult.result.current.addPanel("panel-2"); + }); + act(() => { + hookResult.result.current.addPanel("panel-3"); + }); + + // After 3 panels: ratio should be 2/3 for the outer split + const root = hookResult.result.current.root; + expect(root).not.toBe(null); + expect(root?.type).toBe("split"); + if (root?.type === "split") { + expect(root.ratio).toBe(2 / 3); + expect(root.right).toEqual({ id: "panel-3", type: "panel" }); + } + }); + }); + + describe("removePanel", () => { + it("should remove the only panel and set root to null", () => { + const initialRoot: LayoutNode = { + id: "panel-1", + type: "panel", + }; + + const hookResult = renderDockLayoutHook(initialRoot); + + act(() => { + hookResult.result.current.removePanel("panel-1"); + }); + + expect(hookResult.result.current.root).toBe(null); + expect(hookResult.result.current.layoutRects).toEqual([]); + }); + + it("should remove a panel from a split and promote sibling", () => { + const initialRoot: LayoutNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + + const hookResult = renderDockLayoutHook(initialRoot); + + act(() => { + hookResult.result.current.removePanel("left"); + }); + + expect(hookResult.result.current.root).toEqual({ + id: "right", + type: "panel", + }); + }); + + it("should throw error when removing non-existent panel", () => { + const initialRoot: LayoutNode = { + id: "panel-1", + type: "panel", + }; + + const hookResult = renderDockLayoutHook(initialRoot); + + expect(() => { + hookResult.result.current.removePanel("non-existent"); + }).toThrow("Node with id non-existent not found"); + }); + + it("should throw error when root is null", () => { + const hookResult = renderDockLayoutHook(null); + + expect(() => { + hookResult.result.current.removePanel("any-id"); + }).toThrow("Root node is null"); + }); + }); + + describe("getRectProps", () => { + describe("for panel rect", () => { + it("should return correct style for panel", () => { + const panelRect: PanelLayoutRect = { + id: "panel-1", + type: "panel", + x: 10, + y: 20, + width: 100, + height: 200, + }; + + const hookResult = renderDockLayoutHook(null); + const props = hookResult.result.current.getRectProps(panelRect); + + expect(props.style).toEqual({ + position: "absolute", + left: 10, + top: 20, + width: 100, + height: 200, + }); + }); + + it("should include onPointerMove handler", () => { + const panelRect: PanelLayoutRect = { + id: "panel-1", + type: "panel", + x: 0, + y: 0, + width: 100, + height: 100, + }; + + const hookResult = renderDockLayoutHook(null); + const props = hookResult.result.current.getRectProps(panelRect); + + expect(typeof props.onPointerMove).toBe("function"); + }); + + it("should include onPointerUp handler", () => { + const panelRect: PanelLayoutRect = { + id: "panel-1", + type: "panel", + x: 0, + y: 0, + width: 100, + height: 100, + }; + + const hookResult = renderDockLayoutHook(null); + const props = hookResult.result.current.getRectProps(panelRect); + + expect(typeof props.onPointerUp).toBe("function"); + }); + }); + + describe("for split rect", () => { + it("should return correct style with horizontal cursor for horizontal split", () => { + const splitRect: SplitLayoutRect = { + id: "split-1", + type: "split", + orientation: "horizontal", + x: 50, + y: 0, + width: 10, + height: 100, + }; + + const hookResult = renderDockLayoutHook(null); + const props = hookResult.result.current.getRectProps(splitRect); + + expect(props.style).toEqual({ + position: "absolute", + left: 50, + top: 0, + width: 10, + height: 100, + cursor: "col-resize", + touchAction: "none", + }); + }); + + it("should return correct style with vertical cursor for vertical split", () => { + const splitRect: SplitLayoutRect = { + id: "split-1", + type: "split", + orientation: "vertical", + x: 0, + y: 50, + width: 100, + height: 10, + }; + + const hookResult = renderDockLayoutHook(null); + const props = hookResult.result.current.getRectProps(splitRect); + + expect(props.style).toEqual({ + position: "absolute", + left: 0, + top: 50, + width: 100, + height: 10, + cursor: "row-resize", + touchAction: "none", + }); + }); + + it("should include pointer event handlers", () => { + const splitRect: SplitLayoutRect = { + id: "split-1", + type: "split", + orientation: "horizontal", + x: 50, + y: 0, + width: 10, + height: 100, + }; + + const hookResult = renderDockLayoutHook(null); + const props = hookResult.result.current.getRectProps(splitRect); + + expect(typeof props.onPointerDown).toBe("function"); + expect(typeof props.onPointerMove).toBe("function"); + expect(typeof props.onPointerUp).toBe("function"); + }); + }); + }); + + describe("getDragHandleProps", () => { + it("should return onPointerDown handler and style", () => { + const panelRect: PanelLayoutRect = { + id: "panel-1", + type: "panel", + x: 0, + y: 0, + width: 100, + height: 100, + }; + + const hookResult = renderDockLayoutHook(null); + const props = hookResult.result.current.getDragHandleProps(panelRect); + + expect(typeof props.onPointerDown).toBe("function"); + expect(props.style).toEqual({ touchAction: "none" }); + }); + + it("should set draggingRect when onPointerDown is triggered", () => { + const panelRect: PanelLayoutRect = { + id: "panel-1", + type: "panel", + x: 0, + y: 0, + width: 100, + height: 100, + }; + + const hookResult = renderDockLayoutHook(null); + + expect(hookResult.result.current.draggingRect).toBe(null); + + const props = hookResult.result.current.getDragHandleProps(panelRect); + + act(() => { + const mockEvent = { + currentTarget: { + releasePointerCapture: vi.fn(), + }, + pointerId: 1, + } as unknown as React.PointerEvent; + props.onPointerDown(mockEvent); + }); + + expect(hookResult.result.current.draggingRect).toEqual(panelRect); + }); + }); + + describe("getDropIndicatorProps", () => { + it("should return null when no panel is being dragged", () => { + const panelRect: PanelLayoutRect = { + id: "panel-1", + type: "panel", + x: 0, + y: 0, + width: 100, + height: 100, + }; + + const hookResult = renderDockLayoutHook(null); + const props = hookResult.result.current.getDropIndicatorProps(panelRect); + + expect(props).toBe(null); + }); + + it("should return null for non-target panel when dragging", () => { + const initialRoot: LayoutNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + + const draggingRect: PanelLayoutRect = { + id: "left", + type: "panel", + x: 0, + y: 0, + width: 50, + height: 100, + }; + + const targetRect: PanelLayoutRect = { + id: "right", + type: "panel", + x: 60, + y: 0, + width: 50, + height: 100, + }; + + const hookResult = renderDockLayoutHook(initialRoot); + + // Start dragging + const dragProps = + hookResult.result.current.getDragHandleProps(draggingRect); + act(() => { + const mockEvent = { + currentTarget: { releasePointerCapture: vi.fn() }, + pointerId: 1, + } as unknown as React.PointerEvent; + dragProps.onPointerDown(mockEvent); + }); + + // Without triggering onPointerMove on target, dropTarget is not set + const dropIndicatorProps = + hookResult.result.current.getDropIndicatorProps(targetRect); + expect(dropIndicatorProps).toBe(null); + }); + }); + + describe("draggingRect", () => { + it("should be null initially", () => { + const hookResult = renderDockLayoutHook(null); + expect(hookResult.result.current.draggingRect).toBe(null); + }); + + it("should be set when drag starts", () => { + const panelRect: PanelLayoutRect = { + id: "panel-1", + type: "panel", + x: 0, + y: 0, + width: 100, + height: 100, + }; + + const hookResult = renderDockLayoutHook(null); + const props = hookResult.result.current.getDragHandleProps(panelRect); + + act(() => { + const mockEvent = { + currentTarget: { releasePointerCapture: vi.fn() }, + pointerId: 1, + } as unknown as React.PointerEvent; + props.onPointerDown(mockEvent); + }); + + expect(hookResult.result.current.draggingRect).toEqual(panelRect); + }); + + it("should be cleared when dropped on same panel", () => { + const panelRect: PanelLayoutRect = { + id: "panel-1", + type: "panel", + x: 0, + y: 0, + width: 100, + height: 100, + }; + + const hookResult = renderDockLayoutHook(null); + + // Start dragging + const dragProps = hookResult.result.current.getDragHandleProps(panelRect); + act(() => { + const mockEvent = { + currentTarget: { releasePointerCapture: vi.fn() }, + pointerId: 1, + } as unknown as React.PointerEvent; + dragProps.onPointerDown(mockEvent); + }); + + expect(hookResult.result.current.draggingRect).toEqual(panelRect); + + // Drop on same panel (via panel's onPointerUp) + const rectProps = hookResult.result.current.getRectProps(panelRect); + act(() => { + const mockEvent = { + clientX: 50, + clientY: 50, + } as unknown as React.PointerEvent; + rectProps.onPointerUp(mockEvent); + }); + + expect(hookResult.result.current.draggingRect).toBe(null); + }); + }); + + describe("layoutRects reactivity", () => { + it("should update layoutRects when addPanel is called", () => { + const hookResult = renderDockLayoutHook(null); + + expect(hookResult.result.current.layoutRects).toEqual([]); + + act(() => { + hookResult.result.current.addPanel("panel-1"); + }); + + // Panel was added + expect(hookResult.result.current.root).toEqual({ + id: "panel-1", + type: "panel", + }); + }); + + it("should update layoutRects when removePanel is called", () => { + const initialRoot: LayoutNode = { + id: "root", + type: "split", + orientation: "horizontal", + ratio: 0.5, + left: { id: "left", type: "panel" }, + right: { id: "right", type: "panel" }, + }; + + const hookResult = renderDockLayoutHook(initialRoot); + + act(() => { + hookResult.result.current.removePanel("left"); + }); + + expect(hookResult.result.current.root).toEqual({ + id: "right", + type: "panel", + }); + }); + }); + + describe("custom placement strategy", () => { + it("should use custom placement strategy when adding panels", () => { + const customStrategy = { + getPlacementOnAdd: vi.fn((root: LayoutNode) => ({ + targetId: root.id, + direction: "bottom" as const, + ratio: 0.7, + })), + }; + + const initialRoot: LayoutNode = { + id: "panel-1", + type: "panel", + }; + + const hookResult = renderDockLayoutHook(initialRoot, { + placementStrategy: customStrategy, + }); + + act(() => { + hookResult.result.current.addPanel("panel-2"); + }); + + expect(customStrategy.getPlacementOnAdd).toHaveBeenCalled(); + expect(hookResult.result.current.root).toMatchObject({ + type: "split", + orientation: "vertical", + ratio: 0.7, + }); + }); + }); + + describe("containerRef", () => { + it("should provide a ref callback", () => { + const hookResult = renderDockLayoutHook(null); + expect(hookResult.result.current.containerRef).toBeDefined(); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9bf0907 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + setupFiles: ["./src/test-setup.ts"], + }, +});