diff --git a/package-lock.json b/package-lock.json index 55003d64..06ff2272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,9 +56,11 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.11.1", "tailwindcss": "^4.1.18", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "4.1.10" } }, "node_modules/@babel/code-frame": { @@ -1981,6 +1983,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -3239,6 +3248,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3276,6 +3303,16 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3305,6 +3342,23 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3326,6 +3380,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3344,6 +3511,16 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -3411,6 +3588,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001776", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", @@ -3432,6 +3622,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3574,6 +3774,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -3638,6 +3845,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -3706,6 +3933,38 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -4151,12 +4410,33 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4601,6 +4881,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -4621,6 +4908,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -4652,6 +4953,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4669,6 +4987,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tippy.js": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", @@ -4704,6 +5032,13 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4869,12 +5204,151 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "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 + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index fb889190..dae25d48 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -58,8 +60,10 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "happy-dom": "20.11.1", "tailwindcss": "^4.1.18", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "4.1.10" } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72b6d15d..336cb03c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -122,6 +122,14 @@ pub struct Settings { pub interface_zoom: Option, #[serde(rename = "customEditorWidthPx")] pub custom_editor_width_px: Option, + #[serde(rename = "editorWidthResizeEnabled")] + pub editor_width_resize_enabled: Option, + #[serde(rename = "editorToolbarVisible")] + pub editor_toolbar_visible: Option, + #[serde(rename = "titleBarModifiedDateVisible")] + pub title_bar_modified_date_visible: Option, + #[serde(rename = "titleBarFilenameVisible")] + pub title_bar_filename_visible: Option, /// Custom sidebar width in px; `None` means the default width is used. #[serde(rename = "sidebarWidthPx")] pub sidebar_width_px: Option, @@ -129,6 +137,8 @@ pub struct Settings { pub ollama_model: Option, #[serde(rename = "foldersEnabled")] pub folders_enabled: Option, + #[serde(rename = "sidebarSortOrder")] + pub sidebar_sort_order: Option, #[serde(rename = "ignoredPatterns")] pub ignored_patterns: Option>, #[serde(rename = "customColorsLight")] @@ -3994,3 +4004,46 @@ fn set_title_bar_theme( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::Settings; + + #[test] + fn settings_preserve_sidebar_note_sort_order() { + let settings: Settings = serde_json::from_str( + r#"{"theme":{"mode":"system"},"sidebarSortOrder":"oldest"}"#, + ) + .expect("settings should deserialize"); + + assert_eq!(settings.sidebar_sort_order.as_deref(), Some("oldest")); + + let serialized = serde_json::to_value(settings).expect("settings should serialize"); + assert_eq!(serialized["sidebarSortOrder"], "oldest"); + } + + #[test] + fn settings_preserve_editor_display_preferences() { + let settings: Settings = serde_json::from_str( + r#"{ + "theme":{"mode":"system"}, + "editorWidthResizeEnabled":false, + "editorToolbarVisible":true, + "titleBarModifiedDateVisible":false, + "titleBarFilenameVisible":true + }"#, + ) + .expect("settings should deserialize"); + + assert_eq!(settings.editor_width_resize_enabled, Some(false)); + assert_eq!(settings.editor_toolbar_visible, Some(true)); + assert_eq!(settings.title_bar_modified_date_visible, Some(false)); + assert_eq!(settings.title_bar_filename_visible, Some(true)); + + let serialized = serde_json::to_value(settings).expect("settings should serialize"); + assert_eq!(serialized["editorWidthResizeEnabled"], false); + assert_eq!(serialized["editorToolbarVisible"], true); + assert_eq!(serialized["titleBarModifiedDateVisible"], false); + assert_eq!(serialized["titleBarFilenameVisible"], true); + } +} diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 32a8159c..a095d144 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -71,6 +71,7 @@ import { EditorWidthHandles } from "./EditorWidthHandle"; import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions"; import { cn } from "../../lib/utils"; import { plainTextFromMarkdown } from "../../lib/plainText"; +import { getTitleBarNoteInfoText } from "../../lib/titleBarNoteInfo"; import { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; import * as notesService from "../../services/notes"; import { downloadPdf, downloadMarkdown } from "../../services/pdf"; @@ -546,12 +547,28 @@ export function Editor({ const pinNote = notesCtx?.pinNote; const unpinNote = notesCtx?.unpinNote; const notes = notesCtx?.notes; - const { textDirection } = useTheme(); + const { + textDirection, + editorWidthResizeEnabled, + editorToolbarVisible, + titleBarModifiedDateVisible, + titleBarFilenameVisible, + } = useTheme(); const [isSaving, setIsSaving] = useState(false); // Force re-render when selection changes to update toolbar active states const [, setSelectionKey] = useState(0); const [copyMenuOpen, setCopyMenuOpen] = useState(false); const [settings, setSettings] = useState(null); + const titleBarNoteInfo = currentNote + ? getTitleBarNoteInfoText( + { + modifiedDateVisible: titleBarModifiedDateVisible, + filenameVisible: titleBarFilenameVisible, + }, + currentNote, + formatDateTime, + ) + : null; // Delay transition classes until after initial mount to avoid format bar height animation on note load const [hasTransitioned, setHasTransitioned] = useState(false); useEffect(() => { @@ -2257,9 +2274,11 @@ export function Editor({ )} - - {formatDateTime(currentNote.modified)} - + {titleBarNoteInfo && ( + + {titleBarNoteInfo} + + )}
{/* Format Bar – transition only after initial mount to avoid height animation on note load */} -
- -
+ {editorToolbarVisible && ( +
+ +
+ )} {/* Editor content area with resize handles overlay */}
{!focusMode && !sourceMode && ( - + )}
({ + useTheme: () => ({ + editorWidth: "normal", + customEditorWidthPx: 768, + setEditorWidth: vi.fn(), + setCustomEditorWidthPx: vi.fn(), + setEditorMaxWidthLive: vi.fn(), + }), +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +describe("EditorWidthHandles", () => { + it("measures the rendered page instead of its unconstrained max-width", () => { + const container = document.createElement("div"); + const editor = document.createElement("div"); + editor.className = "ProseMirror"; + editor.style.maxWidth = "576px"; + editor.getBoundingClientRect = () => ({ + x: 37, + y: 0, + left: 37, + top: 0, + right: 563, + bottom: 800, + width: 526, + height: 800, + toJSON: () => ({}), + }); + Object.defineProperty(container, "clientWidth", { value: 600 }); + container.append(editor); + + expect(getRenderedEditorWidth(container)).toBe(526); + }); + + it("caps the measured width at the container width", () => { + const container = document.createElement("div"); + const editor = document.createElement("div"); + editor.className = "ProseMirror"; + editor.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 700, + bottom: 800, + width: 700, + height: 800, + toJSON: () => ({}), + }); + Object.defineProperty(container, "clientWidth", { value: 600 }); + container.append(editor); + + expect(getRenderedEditorWidth(container)).toBe(600); + }); + + it("mounts no resize interaction when mouse resizing is disabled", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + ()} + />, + ); + }); + + expect(container.childElementCount).toBe(0); + + act(() => root.unmount()); + container.remove(); + }); + + it("mounts both resize handles when mouse resizing is enabled", () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + const editorContainer = document.createElement("div"); + const editor = document.createElement("div"); + editor.className = "ProseMirror"; + editor.getBoundingClientRect = () => ({ + x: 216, + y: 0, + left: 216, + top: 0, + right: 984, + bottom: 800, + width: 768, + height: 800, + toJSON: () => ({}), + }); + Object.defineProperty(editorContainer, "clientWidth", { value: 1200 }); + editorContainer.append(editor); + + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(2); + expect( + container.querySelector('[aria-label="Resize editor width (left)"]'), + ).not.toBeNull(); + expect( + container.querySelector('[aria-label="Resize editor width (right)"]'), + ).not.toBeNull(); + + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/components/editor/EditorWidthHandle.tsx b/src/components/editor/EditorWidthHandle.tsx index 44ca86d6..1f532903 100644 --- a/src/components/editor/EditorWidthHandle.tsx +++ b/src/components/editor/EditorWidthHandle.tsx @@ -21,9 +21,33 @@ const SNAP_THRESHOLD = 20; interface EditorWidthHandlesProps { containerRef: RefObject; + enabled: boolean; } -export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) { +export function getRenderedEditorWidth( + container: HTMLDivElement, +): number | null { + const proseMirror = container.querySelector(".ProseMirror"); + if (!proseMirror) return null; + + const renderedWidth = proseMirror.getBoundingClientRect().width; + if (!Number.isFinite(renderedWidth) || renderedWidth <= 0) return null; + + return Math.min(renderedWidth, container.clientWidth); +} + +export function EditorWidthHandles({ + containerRef, + enabled, +}: EditorWidthHandlesProps) { + if (!enabled) return null; + + return ; +} + +function ActiveEditorWidthHandles({ + containerRef, +}: Pick) { const { editorWidth, customEditorWidthPx, @@ -48,17 +72,10 @@ export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) { const updateHandleOffset = useCallback(() => { if (!containerRef.current) return; const containerWidth = containerRef.current.clientWidth; - const proseMirror = - containerRef.current.querySelector(".ProseMirror"); - if (proseMirror) { - const maxWidth = getComputedStyle(proseMirror).maxWidth; - if (maxWidth && maxWidth !== "none") { - const editorPx = - maxWidth === "100%" ? containerWidth : parseFloat(maxWidth); - const clampedEditor = Math.min(editorPx, containerWidth); - setHandleOffset((containerWidth - clampedEditor) / 2); - return; - } + const renderedWidth = getRenderedEditorWidth(containerRef.current); + if (renderedWidth !== null) { + setHandleOffset((containerWidth - renderedWidth) / 2); + return; } setHandleOffset(0); }, [containerRef]); @@ -75,13 +92,8 @@ export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) { const getCurrentEditorWidth = useCallback((): number => { if (!containerRef.current) return 768; - const proseMirror = containerRef.current.querySelector(".ProseMirror"); - if (proseMirror) { - const maxWidth = getComputedStyle(proseMirror).maxWidth; - if (maxWidth && maxWidth !== "none" && maxWidth !== "100%") { - return parseFloat(maxWidth); - } - } + const renderedWidth = getRenderedEditorWidth(containerRef.current); + if (renderedWidth !== null) return renderedWidth; if (editorWidth === "custom") return customEditorWidthPx; if (editorWidth === "full") return containerRef.current.clientWidth; const preset = PRESET_PX.find((p) => p.width === editorWidth); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 2aa1d2db..e967129f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -26,6 +26,8 @@ import { import { mod, shift, isMac, isWindows } from "../../lib/platform"; import * as notesService from "../../services/notes"; import { FolderNameDialog } from "../notes/FolderNameDialog"; +import { NoteSortMenu } from "./SidebarControls"; +import type { NoteSortOrder } from "../../types/note"; interface SidebarProps { onOpenSettings?: () => void; @@ -49,6 +51,8 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [folderDialogParent, setFolderDialogParent] = useState(""); const [foldersEnabled, setFoldersEnabled] = useState(true); + const [noteSortOrder, setNoteSortOrder] = + useState("newest"); const [dragLabel, setDragLabel] = useState(null); const [dragCount, setDragCount] = useState(1); const [multiSelectedNoteIds, setMultiSelectedNoteIds] = useState>(new Set()); @@ -169,12 +173,41 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { useEffect(() => { notesService.getSettings().then((s) => { setFoldersEnabled(s.foldersEnabled === true); + setNoteSortOrder( + s.sidebarSortOrder === "oldest" ? "oldest" : "newest", + ); }).catch((error) => { console.error("Failed to load settings:", error); setFoldersEnabled(false); }); }, []); + const handleNoteSortOrderChange = useCallback( + (nextSortOrder: NoteSortOrder) => { + if (nextSortOrder === noteSortOrder) return; + + const previousSortOrder = noteSortOrder; + setNoteSortOrder(nextSortOrder); + + void notesService + .getSettings() + .then((settings) => + notesService.updateSettings({ + ...settings, + sidebarSortOrder: nextSortOrder, + }), + ) + .catch((error) => { + console.error("Failed to save note sort order:", error); + setNoteSortOrder((current) => + current === nextSortOrder ? previousSortOrder : current, + ); + toast.error("Failed to save note sort order"); + }); + }, + [noteSortOrder], + ); + // Sync input with search query useEffect(() => { setInputValue(searchQuery); @@ -321,6 +354,10 @@ export function Sidebar({ onOpenSettings }: SidebarProps) {
+ { + document.body.replaceChildren(); +}); + +describe("NoteSortMenu", () => { + it("offers newest and oldest ordering and reports the selected option", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const trigger = container.querySelector( + 'button[aria-label="Sort notes: Newest first"]', + ); + expect(trigger).not.toBeNull(); + expect(trigger?.tabIndex).toBe(0); + + act(() => { + trigger?.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + button: 0, + pointerType: "mouse", + }), + ); + }); + + const options = Array.from( + document.body.querySelectorAll('[role="menuitemradio"]'), + ); + expect(options.map((option) => option.textContent?.trim())).toEqual([ + "Newest first", + "Oldest first", + ]); + expect(options[0]?.getAttribute("aria-checked")).toBe("true"); + + act(() => { + options[1]?.click(); + }); + + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledWith("oldest"); + + act(() => root.unmount()); + }); +}); diff --git a/src/components/layout/SidebarControls.tsx b/src/components/layout/SidebarControls.tsx new file mode 100644 index 00000000..27ad856d --- /dev/null +++ b/src/components/layout/SidebarControls.tsx @@ -0,0 +1,73 @@ +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import type { NoteSortOrder } from "../../types/note"; +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, +} from "../icons"; +import { IconButton } from "../ui"; + +interface NoteSortMenuProps { + sortOrder: NoteSortOrder; + onChange: (sortOrder: NoteSortOrder) => void; +} + +const radioItemClass = + "relative flex cursor-pointer items-center gap-2 px-3 py-1.5 pr-8 text-sm text-text outline-none hover:bg-bg-muted focus:bg-bg-muted data-[state=checked]:font-medium"; + +export function NoteSortMenu({ + sortOrder, + onChange, +}: NoteSortMenuProps) { + const newestFirst = sortOrder === "newest"; + + return ( + + + + {newestFirst ? ( + + ) : ( + + )} + + + + + + Sort notes + + { + if (value === "newest" || value === "oldest") onChange(value); + }} + > + + + Newest first + + + + + + + Oldest first + + + + + + + + + ); +} diff --git a/src/components/layout/SidebarFolderSection.test.tsx b/src/components/layout/SidebarFolderSection.test.tsx new file mode 100644 index 00000000..c40e1f83 --- /dev/null +++ b/src/components/layout/SidebarFolderSection.test.tsx @@ -0,0 +1,84 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + SidebarFolderSection, + loadFolderSectionCollapsed, + saveFolderSectionCollapsed, +} from "./SidebarFolderSection"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("folder section persistence", () => { + it("loads only an explicitly collapsed section and saves the next state", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + + expect(loadFolderSectionCollapsed(storage)).toBe(false); + values.set("scratch:foldersSectionCollapsed", "true"); + expect(loadFolderSectionCollapsed(storage)).toBe(true); + + saveFolderSectionCollapsed(false, storage); + expect(values.get("scratch:foldersSectionCollapsed")).toBe("false"); + }); +}); + +describe("SidebarFolderSection", () => { + it("uses one disclosure control to hide and reveal the complete folder group", () => { + const onCollapsedChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + +
Folder tree
+
, + ); + }); + + const collapseButton = container.querySelector( + 'button[aria-label="Collapse Folders"]', + ); + expect(collapseButton?.getAttribute("aria-expanded")).toBe("true"); + expect(container.textContent).toContain("Folders"); + expect(container.querySelector('[data-testid="folder-group"]')).not.toBeNull(); + + act(() => collapseButton?.click()); + expect(onCollapsedChange).toHaveBeenCalledWith(true); + + act(() => { + root.render( + +
Folder tree
+
, + ); + }); + + const expandButton = container.querySelector( + 'button[aria-label="Expand Folders"]', + ); + expect(expandButton?.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector('[data-testid="folder-group"]')).toBeNull(); + + act(() => expandButton?.click()); + expect(onCollapsedChange).toHaveBeenLastCalledWith(false); + + act(() => root.unmount()); + }); +}); diff --git a/src/components/layout/SidebarFolderSection.tsx b/src/components/layout/SidebarFolderSection.tsx new file mode 100644 index 00000000..5431ebba --- /dev/null +++ b/src/components/layout/SidebarFolderSection.tsx @@ -0,0 +1,72 @@ +import { useId, type ReactNode } from "react"; +import { ChevronRightIcon } from "../icons"; + +const STORAGE_KEY = "scratch:foldersSectionCollapsed"; + +type SidebarStorage = Pick; + +function defaultStorage(): SidebarStorage | undefined { + try { + return globalThis.localStorage; + } catch { + return undefined; + } +} + +export function loadFolderSectionCollapsed( + storage: SidebarStorage | undefined = defaultStorage(), +): boolean { + try { + return storage?.getItem(STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +export function saveFolderSectionCollapsed( + collapsed: boolean, + storage: SidebarStorage | undefined = defaultStorage(), +): void { + try { + storage?.setItem(STORAGE_KEY, String(collapsed)); + } catch { + // Keep the disclosure usable when storage is unavailable. + } +} + +interface SidebarFolderSectionProps { + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; + children: ReactNode; +} + +export function SidebarFolderSection({ + collapsed, + onCollapsedChange, + children, +}: SidebarFolderSectionProps) { + const contentId = useId(); + const expanded = !collapsed; + + return ( +
+ + {expanded &&
{children}
} +
+ ); +} diff --git a/src/components/notes/FolderTreeView.test.tsx b/src/components/notes/FolderTreeView.test.tsx new file mode 100644 index 00000000..ac9f83cd --- /dev/null +++ b/src/components/notes/FolderTreeView.test.tsx @@ -0,0 +1,67 @@ +import { DndContext } from "@dnd-kit/core"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import type { FolderNode } from "../../types/note"; +import { FolderItemComponent } from "./FolderTreeView"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + +describe("FolderItemComponent", () => { + it("does not expose a per-folder descendant-collapse action", () => { + const child: FolderNode = { + name: "docs", + path: "Point/docs", + children: [], + notes: [], + }; + const parent: FolderNode = { + name: "Point", + path: "Point", + children: [child], + notes: [], + }; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + + undefined)} + onUnpinNote={vi.fn(async () => undefined)} + onDuplicateNote={vi.fn(async () => undefined)} + onDeleteNote={vi.fn()} + onMoveNoteToParent={vi.fn()} + onMoveFolderToParent={vi.fn()} + /> + , + ); + }); + + expect( + container.querySelector( + 'button[aria-label="Collapse all subfolders in Point"]', + ), + ).toBeNull(); + expect(container.querySelectorAll("button")).toHaveLength(0); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/notes/FolderTreeView.tsx b/src/components/notes/FolderTreeView.tsx index 3f7998a3..d0b8a981 100644 --- a/src/components/notes/FolderTreeView.tsx +++ b/src/components/notes/FolderTreeView.tsx @@ -5,7 +5,7 @@ import { useNotes } from "../../context/NotesContext"; import { buildFolderTree, countNotesInFolder, - getVisibleItems, + getVisibleItemsForFolderSection, type TreeItem, } from "../../lib/folderTree"; import { FolderNameDialog } from "./FolderNameDialog"; @@ -35,7 +35,17 @@ import { ArrowUpIcon, } from "../icons"; import * as notesService from "../../services/notes"; -import type { FolderNode, NoteMetadata, Settings } from "../../types/note"; +import { + SidebarFolderSection, + loadFolderSectionCollapsed, + saveFolderSectionCollapsed, +} from "../layout/SidebarFolderSection"; +import type { + FolderNode, + NoteMetadata, + NoteSortOrder, + Settings, +} from "../../types/note"; const STORAGE_KEY = "scratch:collapsedFolders"; @@ -269,7 +279,7 @@ interface FolderItemProps { onMoveFolderToParent: (path: string, targetParent: string) => void; } -const FolderItemComponent = memo(function FolderItem({ +export const FolderItemComponent = memo(function FolderItem({ folder, depth, collapsedFolders, @@ -474,6 +484,7 @@ const FolderItemComponent = memo(function FolderItem({ }); interface FolderTreeViewProps { + sortOrder: NoteSortOrder; pinnedIds: Set; settings: Settings | null; multiSelectedNoteIds: Set; @@ -483,6 +494,7 @@ interface FolderTreeViewProps { } export function FolderTreeView({ + sortOrder, pinnedIds, settings: _settings, multiSelectedNoteIds, @@ -508,6 +520,9 @@ export function FolderTreeView({ const [collapsedFolders, setCollapsedFolders] = useState>(loadCollapsedFolders); + const [foldersSectionCollapsed, setFoldersSectionCollapsed] = useState( + loadFolderSectionCollapsed, + ); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [folderToDelete, setFolderToDelete] = useState(null); const [renameDialogOpen, setRenameDialogOpen] = useState(false); @@ -534,9 +549,13 @@ export function FolderTreeView({ saveCollapsedFolders(collapsedFolders); }, [collapsedFolders]); + useEffect(() => { + saveFolderSectionCollapsed(foldersSectionCollapsed); + }, [foldersSectionCollapsed]); + const tree = useMemo( - () => buildFolderTree(notes, pinnedIds, knownFolders), - [notes, pinnedIds, knownFolders], + () => buildFolderTree(notes, pinnedIds, knownFolders, sortOrder), + [notes, pinnedIds, knownFolders, sortOrder], ); const handleToggleCollapse = useCallback((path: string) => { @@ -554,6 +573,7 @@ export function FolderTreeView({ // Expand a folder and all its ancestors const expandFolder = useCallback((folderPath: string) => { if (!folderPath) return; + setFoldersSectionCollapsed(false); setCollapsedFolders((prev) => { const next = new Set(prev); // Expand this folder and every ancestor @@ -663,8 +683,14 @@ export function FolderTreeView({ // Flat list of visible items for keyboard navigation const visibleItems = useMemo( - () => getVisibleItems(tree, pinnedIds, collapsedFolders), - [tree, pinnedIds, collapsedFolders], + () => + getVisibleItemsForFolderSection( + tree, + pinnedIds, + collapsedFolders, + foldersSectionCollapsed, + ), + [tree, pinnedIds, collapsedFolders, foldersSectionCollapsed], ); // Visible note IDs in order (for Shift+Click range computation) @@ -875,30 +901,37 @@ export function FolderTreeView({ ))} {/* Folders */} - {tree.folders.map((folder) => ( - - ))} + {tree.folders.length > 0 && ( + + {tree.folders.map((folder) => ( + + ))} + + )} {/* Unpinned root notes */} {unpinnedRootNotes.map((note) => ( diff --git a/src/components/notes/NoteList.tsx b/src/components/notes/NoteList.tsx index 3e761f39..8d5a32b9 100644 --- a/src/components/notes/NoteList.tsx +++ b/src/components/notes/NoteList.tsx @@ -21,7 +21,8 @@ import { CopyIcon, TrashIcon, } from "../icons"; -import type { Settings } from "../../types/note"; +import type { NoteSortOrder, Settings } from "../../types/note"; +import { sortNotesByModified } from "../../lib/folderTree"; const menuItemClass = "px-3 py-1.5 text-sm text-text cursor-pointer outline-none hover:bg-bg-muted focus:bg-bg-muted flex items-center gap-2 rounded-sm"; @@ -227,6 +228,7 @@ const NoteItemWithMenu = memo(function NoteItemWithMenu({ }); interface NoteListProps { + sortOrder: NoteSortOrder; multiSelectedNoteIds: Set; setMultiSelectedNoteIds: React.Dispatch>>; lastClickedNoteId: string | null; @@ -234,6 +236,7 @@ interface NoteListProps { } export function NoteList({ + sortOrder, multiSelectedNoteIds, setMultiSelectedNoteIds, lastClickedNoteId, @@ -307,6 +310,11 @@ export function NoteList({ return notes; }, [searchQuery, searchResults, notes]); + const sortedDisplayItems = useMemo( + () => sortNotesByModified(displayItems, sortOrder), + [displayItems, sortOrder], + ); + // Listen for focus request from editor (when Escape is pressed) useEffect(() => { const handleFocusNoteList = () => { @@ -341,7 +349,7 @@ export function NoteList({ ); } - if (isSearching && displayItems.length === 0) { + if (isSearching && sortedDisplayItems.length === 0) { return (
No results found @@ -349,7 +357,7 @@ export function NoteList({ ); } - if (displayItems.length === 0) { + if (sortedDisplayItems.length === 0) { return (
No notes yet @@ -362,6 +370,7 @@ export function NoteList({ return ( <> - {displayItems.map((item) => ( + {sortedDisplayItems.map((item) => ( { + it("exposes the current state and lets the user disable mouse resizing", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const group = container.querySelector( + '[role="group"][aria-label="Resize editor with mouse"]', + ); + const [offButton, onButton] = Array.from( + container.querySelectorAll("button"), + ); + + expect(group).not.toBeNull(); + expect(offButton.textContent).toBe("Off"); + expect(offButton.getAttribute("aria-pressed")).toBe("false"); + expect(onButton.textContent).toBe("On"); + expect(onButton.getAttribute("aria-pressed")).toBe("true"); + + act(() => offButton.click()); + expect(onChange).toHaveBeenCalledWith(false); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("EditorToolbarVisibilityControl", () => { + it("exposes the hidden default and lets the user show the toolbar", () => { + const onChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const group = container.querySelector( + '[role="group"][aria-label="Show formatting toolbar"]', + ); + const [offButton, onButton] = Array.from( + container.querySelectorAll("button"), + ); + + expect(group).not.toBeNull(); + expect(offButton.textContent).toBe("Off"); + expect(offButton.getAttribute("aria-pressed")).toBe("true"); + expect(onButton.textContent).toBe("On"); + expect(onButton.getAttribute("aria-pressed")).toBe("false"); + + act(() => onButton.click()); + expect(onChange).toHaveBeenCalledWith(true); + + act(() => root.unmount()); + container.remove(); + }); +}); + +describe("TitleBarNoteInfoControls", () => { + it("offers one exclusive title-bar information menu", () => { + const onModifiedDateChange = vi.fn(); + const onFilenameChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const select = container.querySelector( + 'select[aria-label="Title bar information"]', + ); + expect(select).not.toBeNull(); + expect(container.querySelectorAll("select")).toHaveLength(1); + expect(Array.from(select?.options ?? []).map((option) => option.text)).toEqual( + ["Modification Date", "Filename", "None"], + ); + expect(select?.value).toBe("modifiedDate"); + expect(container.textContent).not.toContain("On"); + expect(container.textContent).not.toContain("Off"); + + act(() => { + if (!select) return; + select.value = "filename"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(onFilenameChange).toHaveBeenCalledWith(true); + expect(onModifiedDateChange).not.toHaveBeenCalled(); + + act(() => root.unmount()); + container.remove(); + }); + + it("maps None to the single active persisted setting", () => { + const onModifiedDateChange = vi.fn(); + const onFilenameChange = vi.fn(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const select = container.querySelector( + 'select[aria-label="Title bar information"]', + ); + expect(select?.value).toBe("filename"); + + act(() => { + if (!select) return; + select.value = "none"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onFilenameChange).toHaveBeenCalledWith(false); + expect(onModifiedDateChange).not.toHaveBeenCalled(); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/components/settings/EditorSettingsSection.tsx b/src/components/settings/EditorSettingsSection.tsx index 997e9180..96702692 100644 --- a/src/components/settings/EditorSettingsSection.tsx +++ b/src/components/settings/EditorSettingsSection.tsx @@ -56,6 +56,161 @@ const boldWeightOptions = [ { value: 800, label: "Extra Bold", excludeForMonospace: false }, ]; +interface EditorWidthResizeControlProps { + enabled: boolean; + onChange: (enabled: boolean) => void; +} + +interface BinarySettingControlProps { + label: string; + description: string; + ariaLabel: string; + value: boolean; + onChange: (value: boolean) => void; +} + +function BinarySettingControl({ + label, + description, + ariaLabel, + value, + onChange, +}: BinarySettingControlProps) { + return ( +
+
+
{label}
+

{description}

+
+
+ + +
+
+ ); +} + +export function EditorWidthResizeControl({ + enabled, + onChange, +}: EditorWidthResizeControlProps) { + return ( + + ); +} + +interface EditorToolbarVisibilityControlProps { + visible: boolean; + onChange: (visible: boolean) => void; +} + +export function EditorToolbarVisibilityControl({ + visible, + onChange, +}: EditorToolbarVisibilityControlProps) { + return ( + + ); +} + +interface TitleBarNoteInfoControlsProps { + modifiedDateVisible: boolean; + filenameVisible: boolean; + onModifiedDateChange: (visible: boolean) => void; + onFilenameChange: (visible: boolean) => void; +} + +type TitleBarNoteInfoMode = "modifiedDate" | "filename" | "none"; + +export function TitleBarNoteInfoControls({ + modifiedDateVisible, + filenameVisible, + onModifiedDateChange, + onFilenameChange, +}: TitleBarNoteInfoControlsProps) { + let mode: TitleBarNoteInfoMode = "none"; + if (filenameVisible) { + mode = "filename"; + } else if (modifiedDateVisible) { + mode = "modifiedDate"; + } + + const handleModeChange = (nextMode: TitleBarNoteInfoMode) => { + if (nextMode === "filename") { + onFilenameChange(true); + return; + } + + if (nextMode === "modifiedDate") { + onModifiedDateChange(true); + return; + } + + if (filenameVisible) { + onFilenameChange(false); + } else if (modifiedDateVisible) { + onModifiedDateChange(false); + } + }; + + return ( +
+
+
+ Title Bar Information +
+

+ Choose what appears beside the note icon +

+
+ +
+ ); +} + export function AppearanceSettingsSection() { const { theme, @@ -72,6 +227,14 @@ export function AppearanceSettingsSection() { setInterfaceZoom, customEditorWidthPx, setCustomEditorWidthPx, + editorWidthResizeEnabled, + setEditorWidthResizeEnabled, + editorToolbarVisible, + setEditorToolbarVisible, + titleBarModifiedDateVisible, + setTitleBarModifiedDateVisible, + titleBarFilenameVisible, + setTitleBarFilenameVisible, customColorsLight, customColorsDark, setCustomColor, @@ -92,14 +255,18 @@ export function AppearanceSettingsSection() { setEditorFontSetting(field, clamped); }; - // Check if settings differ from defaults - const hasCustomFonts = + // Check if appearance settings differ from defaults + const hasCustomAppearanceSettings = editorFontSettings.baseFontFamily !== "system-sans" || editorFontSettings.baseFontSize !== 15 || editorFontSettings.boldWeight !== 600 || editorFontSettings.lineHeight !== 1.6 || textDirection !== "auto" || editorWidth !== "normal" || + !editorWidthResizeEnabled || + editorToolbarVisible || + !titleBarModifiedDateVisible || + titleBarFilenameVisible || Math.round(interfaceZoom * 100) !== 100; // Filter weight options based on font family @@ -183,7 +350,7 @@ export function AppearanceSettingsSection() {

Typography

- {hasCustomFonts && ( + {hasCustomAppearanceSettings && ( @@ -324,6 +491,23 @@ export function AppearanceSettingsSection() {
)} + + + + + + {/* Interface Zoom */}