diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f21e5709..b732badc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,10 +211,12 @@ jobs: NODE_OPTIONS: '--max-old-space-size=8192' - name: Start Storybook server - run: pnpm dlx http-server storybook-static --port 6006 --silent & + # pnpm exec uses the pinned devDependency — pnpm dlx downloads from + # the npm registry at run time and times out during registry outages. + run: pnpm exec http-server storybook-static --port 6006 --silent & - name: Wait for Storybook - run: pnpm dlx wait-on http://localhost:6006 + run: pnpm exec wait-on http://localhost:6006 - name: Run Storybook tests run: pnpm test:storybook diff --git a/package.json b/package.json index 12d8e8d1..e303bb1b 100644 --- a/package.json +++ b/package.json @@ -426,6 +426,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-storybook": "^10.2.11", "fake-indexeddb": "^6.2.5", + "http-server": "14.1.1", "js-yaml": "^4.1.1", "jsdom": "^26.1.0", "katex": "^0.17.0", @@ -450,6 +451,7 @@ "typescript": "^5.9.3", "vite": "^7.3.2", "vitest": "^3.2.6", + "wait-on": "^9.1.0", "wavesurfer.js": "^7.12.1", "y-protocols": "^1.0.6", "y-websocket": "^3.0.0", diff --git a/playwright.config.ts b/playwright.config.ts index c3935b61..2b826b90 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -52,7 +52,10 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'npx http-server storybook-static -p 6006 -c-1 --silent', + // http-server is a pinned devDependency so this never hits the npm + // registry at test time (npx-on-demand installs caused CI timeouts + // during registry outages). + command: 'pnpm exec http-server storybook-static -p 6006 -c-1 --silent', url: 'http://localhost:6006', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, // 2 minutes for Storybook to start diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43b6803c..7278b9c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: fake-indexeddb: specifier: ^6.2.5 version: 6.2.5 + http-server: + specifier: 14.1.1 + version: 14.1.1 js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -328,6 +331,9 @@ importers: vitest: specifier: ^3.2.6 version: 3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(sass@1.100.0) + wait-on: + specifier: ^9.1.0 + version: 9.1.0 wavesurfer.js: specifier: ^7.12.1 version: 7.12.1 @@ -852,12 +858,32 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hapi/address@5.1.1': + resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} + engines: {node: '>=14.0.0'} + + '@hapi/formula@3.0.2': + resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} + + '@hapi/hoek@11.0.7': + resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + '@hapi/pinpoint@2.0.1': + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + + '@hapi/tlds@1.1.7': + resolution: {integrity: sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==} + engines: {node: '>=14.0.0'} + '@hapi/topo@5.1.0': resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@hapi/topo@6.0.2': + resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2381,6 +2407,10 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2501,6 +2531,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2533,6 +2566,9 @@ packages: axios@1.16.0: resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2577,6 +2613,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -2798,6 +2838,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + corser@2.0.1: + resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==} + engines: {node: '>= 0.4.0'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -3374,6 +3418,9 @@ packages: event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -3502,6 +3549,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -3652,6 +3703,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} @@ -3685,6 +3740,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + highlight.js@11.11.1: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} @@ -3693,6 +3752,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -3717,6 +3780,19 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + http-server@14.1.1: + resolution: {integrity: sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==} + engines: {node: '>=12'} + hasBin: true + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -4160,6 +4236,10 @@ packages: joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + joi@18.2.8: + resolution: {integrity: sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==} + engines: {node: '>= 20'} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4628,6 +4708,11 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -4779,6 +4864,10 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4926,6 +5015,10 @@ packages: points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + portfinder@1.0.38: + resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} + engines: {node: '>= 10.12'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5083,6 +5176,10 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -5229,6 +5326,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -5320,6 +5420,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-compare@3.0.1: + resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -5356,6 +5459,10 @@ packages: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + side-channel-map@1.0.1: resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} engines: {node: '>= 0.4'} @@ -5368,6 +5475,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -5764,6 +5875,10 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + union@0.5.0: + resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} + engines: {node: '>= 0.8.0'} + unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} @@ -5801,6 +5916,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -5914,6 +6032,11 @@ packages: engines: {node: '>=12.0.0'} hasBin: true + wait-on@9.1.0: + resolution: {integrity: sha512-PymrLXHLBM1Ju/Xspb2ADUhbPSMvbnuNvy/mN2hWtpbJ3da0h3Ky1LqwKPG5QSVR57liyO0iUpfipYl/s5qNvA==} + engines: {node: '>=20.0.0'} + hasBin: true + wait-port@0.2.14: resolution: {integrity: sha512-kIzjWcr6ykl7WFbZd0TMae8xovwqcqbx6FM9l+7agOgUByhzdjfzZBPK2CPufldTOMxbUivss//Sh9MFawmPRQ==} engines: {node: '>=8'} @@ -5938,6 +6061,11 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -6712,12 +6840,28 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@hapi/address@5.1.1': + dependencies: + '@hapi/hoek': 11.0.7 + + '@hapi/formula@3.0.2': {} + + '@hapi/hoek@11.0.7': {} + '@hapi/hoek@9.3.0': {} + '@hapi/pinpoint@2.0.1': {} + + '@hapi/tlds@1.1.7': {} + '@hapi/topo@5.1.0': dependencies: '@hapi/hoek': 9.3.0 + '@hapi/topo@6.0.2': + dependencies: + '@hapi/hoek': 11.0.7 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -8375,6 +8519,12 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} aggregate-error@3.1.0: @@ -8513,6 +8663,8 @@ snapshots: async-function@1.0.0: {} + async@3.2.6: {} + asynckit@0.4.0: {} autoprefixer@10.4.24(postcss@8.5.15): @@ -8552,6 +8704,16 @@ snapshots: transitivePeerDependencies: - debug + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axobject-query@4.1.0: {} babel-jest@30.3.0(@babel/core@7.29.0): @@ -8614,6 +8776,10 @@ snapshots: baseline-browser-mapping@2.10.0: {} + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + bignumber.js@9.3.1: {} bootstrap@5.3.8(@popperjs/core@2.11.8): @@ -8818,6 +8984,8 @@ snapshots: core-util-is@1.0.3: {} + corser@2.0.1: {} + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -9553,6 +9721,8 @@ snapshots: d: 1.0.2 es5-ext: 0.10.64 + eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} execa@5.1.1: @@ -9684,6 +9854,14 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + fraction.js@5.3.4: {} fromentries@1.3.2: {} @@ -9831,6 +10009,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-from-dom@5.0.1: dependencies: '@types/hast': 3.0.4 @@ -9917,12 +10099,18 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 + he@1.2.0: {} + highlight.js@11.11.1: {} homedir-polyfill@1.0.3: dependencies: parse-passwd: 1.0.0 + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -9953,6 +10141,40 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.16.0 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + http-server@14.1.1: + dependencies: + basic-auth: 2.0.1 + chalk: 4.1.2 + corser: 2.0.1 + he: 1.2.0 + html-encoding-sniffer: 3.0.0 + http-proxy: 1.18.1 + mime: 1.6.0 + minimist: 1.2.8 + opener: 1.5.2 + portfinder: 1.0.38 + secure-compare: 3.0.1 + union: 0.5.0 + url-join: 4.0.1 + transitivePeerDependencies: + - debug + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -10606,6 +10828,16 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + joi@18.2.8: + dependencies: + '@hapi/address': 5.1.1 + '@hapi/formula': 3.0.2 + '@hapi/hoek': 11.0.7 + '@hapi/pinpoint': 2.0.1 + '@hapi/tlds': 1.1.7 + '@hapi/topo': 6.0.2 + '@standard-schema/spec': 1.1.0 + joycon@3.1.1: {} js-tokens@10.0.0: {} @@ -11269,6 +11501,8 @@ snapshots: dependencies: mime-db: 1.52.0 + mime@1.6.0: {} + mimic-fn@2.1.0: {} min-indent@1.0.1: {} @@ -11446,6 +11680,8 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + opener@1.5.2: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -11588,6 +11824,13 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 + portfinder@1.0.38: + dependencies: + async: 3.2.6 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + possible-typed-array-names@1.1.0: {} postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.15): @@ -11696,6 +11939,11 @@ snapshots: pure-rand@7.0.1: {} + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -11921,6 +12169,8 @@ snapshots: require-main-filename@2.0.0: {} + requires-port@1.0.0: {} + reselect@5.2.0: {} resolve-cwd@3.0.0: @@ -12044,6 +12294,8 @@ snapshots: scheduler@0.27.0: {} + secure-compare@3.0.1: {} + semver@6.3.1: {} semver@7.7.4: {} @@ -12083,6 +12335,11 @@ snapshots: es-errors: 1.3.0 object-inspect: 1.13.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-map@1.0.1: dependencies: call-bound: 1.0.4 @@ -12106,6 +12363,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -12558,6 +12823,10 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + union@0.5.0: + dependencies: + qs: 6.16.0 + unist-util-find-after@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -12632,6 +12901,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-join@4.0.1: {} + use-sync-external-store@1.6.0(react@19.2.4): dependencies: react: 19.2.4 @@ -12773,6 +13044,17 @@ snapshots: transitivePeerDependencies: - debug + wait-on@9.1.0: + dependencies: + axios: 1.20.0 + joi: 18.2.8 + lodash: 4.18.1 + minimist: 1.2.8 + rxjs: 7.8.2 + transitivePeerDependencies: + - debug + - supports-color + wait-port@0.2.14: dependencies: chalk: 2.4.2 @@ -12795,6 +13077,10 @@ snapshots: webpack-virtual-modules@0.6.2: {} + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 diff --git a/src/components/FilterSummaryBar/FilterSummaryBar.stories.tsx b/src/components/FilterSummaryBar/FilterSummaryBar.stories.tsx new file mode 100644 index 00000000..dfd389df --- /dev/null +++ b/src/components/FilterSummaryBar/FilterSummaryBar.stories.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Button } from '../Button'; +import { FilterSummaryBar } from './FilterSummaryBar'; + +const meta: Meta = { + title: 'Components/Data Display/FilterSummaryBar', + component: FilterSummaryBar, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'The bar that anchors a filtered grid: "1,204 of 8,911 records — 3 filters active ' + + '· Clear all". Hidden while idle unless `showWhenIdle`; tinted with the primary ' + + 'accent whenever filters or search narrow the view. All visible strings are ' + + 'overridable for i18n.', + }, + }, + }, + tags: ['autodocs'], + argTypes: { + filteredCount: { + description: 'Rows visible after filtering.', + control: 'number', + }, + totalCount: { + description: 'Total records before filtering.', + control: 'number', + }, + activeFilterCount: { + description: 'Active filter conditions.', + control: 'number', + }, + hasSearchText: { + description: 'Whether a search query is active.', + control: 'boolean', + }, + showWhenIdle: { + description: 'Show even with nothing active.', + control: 'boolean', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + filteredCount: 1204, + totalCount: 8911, + activeFilterCount: 3, + onClearAll: () => {}, + }, +}; + +export const FiltersPlusSearch: Story = { + args: { + filteredCount: 42, + totalCount: 8911, + activeFilterCount: 2, + hasSearchText: true, + onClearAll: () => {}, + }, +}; + +export const SearchOnly: Story = { + args: { + filteredCount: 310, + totalCount: 8911, + activeFilterCount: 0, + hasSearchText: true, + onClearAll: () => {}, + }, +}; + +export const Idle: Story = { + args: { + filteredCount: 8911, + totalCount: 8911, + activeFilterCount: 0, + showWhenIdle: true, + onClearAll: () => {}, + }, +}; + +export const Interactive: Story = { + render: () => , +}; + +function InteractiveExample() { + const [filters, setFilters] = useState(3); + const filtered = Math.max(120, 8911 - filters * 2600); + return ( +
+ 0 ? filtered : 8911} + totalCount={8911} + activeFilterCount={filters} + showWhenIdle + onClearAll={() => setFilters(0)} + /> + +
+ ); +} diff --git a/src/components/FilterSummaryBar/FilterSummaryBar.test.tsx b/src/components/FilterSummaryBar/FilterSummaryBar.test.tsx new file mode 100644 index 00000000..80e4816d --- /dev/null +++ b/src/components/FilterSummaryBar/FilterSummaryBar.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithTheme } from '../../test/test-utils'; +import { FilterSummaryBar } from './FilterSummaryBar'; + +describe('FilterSummaryBar', () => { + it('hides while idle by default', () => { + renderWithTheme( + {}} + /> + ); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows idle summary with showWhenIdle', () => { + renderWithTheme( + {}} + /> + ); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('all records visible'); + expect(status).not.toHaveTextContent('100'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('summarizes counts and pluralizes filters', () => { + renderWithTheme( + {}} + /> + ); + const status = screen.getByRole('status'); + expect(status).toHaveTextContent( + `${(1204).toLocaleString()} of ${(8911).toLocaleString()} records` + ); + expect(status).toHaveTextContent('3 filters active'); + }); + + it('uses the singular filter label', () => { + renderWithTheme( + {}} + /> + ); + expect(screen.getByRole('status')).toHaveTextContent('1 filter active'); + }); + + it('uses the singular record label when one row is visible', () => { + renderWithTheme( + {}} + /> + ); + expect(screen.getByRole('status')).toHaveTextContent( + `1 of ${(8911).toLocaleString()} record —` + ); + }); + + it('renders a real total of zero', () => { + renderWithTheme( + {}} + /> + ); + expect(screen.getByRole('status')).toHaveTextContent('0 of 0 records'); + }); + + it('hides the clear button when onClearAll is omitted', () => { + renderWithTheme( + + ); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('describes search-only and search+filters states', () => { + const { rerender } = renderWithTheme( + {}} + /> + ); + expect(screen.getByRole('status')).toHaveTextContent('search active'); + + rerender( + {}} + /> + ); + expect(screen.getByRole('status')).toHaveTextContent( + '2 filters active + search' + ); + }); + + it('clears all filters', () => { + const onClearAll = vi.fn(); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: /clear all/i })); + expect(onClearAll).toHaveBeenCalled(); + }); + + it('supports i18n label overrides', () => { + renderWithTheme( + {}} + recordsLabel="contacts" + filterLabel="condition" + clearLabel="Reset" + /> + ); + expect(screen.getByRole('status')).toHaveTextContent('5 contacts'); + expect(screen.getByRole('status')).toHaveTextContent('1 condition active'); + expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument(); + }); +}); diff --git a/src/components/FilterSummaryBar/FilterSummaryBar.tsx b/src/components/FilterSummaryBar/FilterSummaryBar.tsx new file mode 100644 index 00000000..2784ee71 --- /dev/null +++ b/src/components/FilterSummaryBar/FilterSummaryBar.tsx @@ -0,0 +1,167 @@ +'use client'; + +import * as React from 'react'; +import { Filter, X } from 'lucide-react'; +import { cn } from '../../utils/cn'; + +export interface FilterSummaryBarProps extends React.HTMLAttributes { + /** Rows currently visible after filtering. */ + filteredCount: number; + /** Total records before filtering. Omit when unknown; `0` renders as a real total. */ + totalCount?: number; + /** Number of active filter conditions. */ + activeFilterCount: number; + /** Whether a search query is also active. */ + hasSearchText?: boolean; + /** + * Keep the bar visible even when no filters/search are active. The idle + * bar shows only `allVisibleLabel` (no counts). + */ + showWhenIdle?: boolean; + /** Called when the clear action is clicked. Omit to hide the clear button. */ + onClearAll?: () => void; + /** Visible strings, overridable for i18n. */ + recordLabel?: string; + recordsLabel?: string; + filterLabel?: string; + filtersLabel?: string; + activeLabel?: string; + searchActiveLabel?: string; + allVisibleLabel?: string; + clearLabel?: string; + /** Joiner between filtered and total counts (default "of"). */ + ofLabel?: string; + /** Suffix appended when search narrows on top of filters (default "+ search"). */ + plusSearchLabel?: string; +} + +/** + * The bar that anchors a filtered grid: "1,204 of 8,911 records — 3 + * filters active · Clear all". Hidden while idle unless `showWhenIdle`, + * and tinted with the primary accent whenever filters or search narrow + * the view. Pair with any data grid or list whose filter state lives in + * the host. + * + * @example + * ```tsx + * 0} + * onClearAll={resetFilters} + * /> + * ``` + */ +export const FilterSummaryBar = React.forwardRef< + HTMLDivElement, + FilterSummaryBarProps +>(function FilterSummaryBar( + { + filteredCount, + totalCount, + activeFilterCount, + hasSearchText = false, + showWhenIdle = false, + onClearAll, + recordLabel = 'record', + recordsLabel = 'records', + filterLabel = 'filter', + filtersLabel = 'filters', + activeLabel = 'active', + searchActiveLabel = 'search active', + allVisibleLabel = 'all records visible', + clearLabel = 'Clear all', + ofLabel = 'of', + plusSearchLabel = '+ search', + className, + ...props + }, + ref +) { + const isFiltering = activeFilterCount > 0 || hasSearchText; + if (!isFiltering && !showWhenIdle) return null; + + return ( +
+
+ ); +}); + +FilterSummaryBar.displayName = 'FilterSummaryBar'; diff --git a/src/components/FilterSummaryBar/index.ts b/src/components/FilterSummaryBar/index.ts new file mode 100644 index 00000000..de7c27c3 --- /dev/null +++ b/src/components/FilterSummaryBar/index.ts @@ -0,0 +1,4 @@ +export { + FilterSummaryBar, + type FilterSummaryBarProps, +} from './FilterSummaryBar'; diff --git a/src/index.ts b/src/index.ts index a01c455b..eb84230c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,6 +74,7 @@ export * from './components/EmployerView'; export * from './components/EmployerServiceModal'; export * from './components/ErrorPage'; export * from './components/FileManager'; +export * from './components/FilterSummaryBar'; export * from './components/FloatingWindow'; export * from './components/FreshnessBadge'; export * from './components/GlossaryTooltip'; diff --git a/src/tailwind-preset.cjs b/src/tailwind-preset.cjs index 9639e93e..3d834169 100644 --- a/src/tailwind-preset.cjs +++ b/src/tailwind-preset.cjs @@ -130,6 +130,20 @@ module.exports = { 'font-mono', 'mx-0.5', 'list-none', + // FilterSummaryBar — filtered-grid summary bar and clear action + 'border-primary-300', + 'dark:border-primary-700', + 'bg-muted/60', + 'dark:text-primary-200', + 'rounded-md', + 'py-1', + 'text-primary-700', + 'hover:bg-primary-500/15', + 'dark:text-primary-300', + 'dark:hover:text-primary-200', + 'h-2.5', + 'w-2.5', + 'focus-visible:ring-offset-2', // Sparkline — bar sizing, gaps, and hover tints 'min-w-[3px]', 'gap-[2px]', diff --git a/src/tailwind-preset.ts b/src/tailwind-preset.ts index 94550c8a..9d0ca914 100644 --- a/src/tailwind-preset.ts +++ b/src/tailwind-preset.ts @@ -604,6 +604,12 @@ export const miewebUISafelist = [ 'font-mono', 'mx-0.5', 'list-none', + // FilterSummaryBar — filtered-grid summary bar and clear action + 'py-1', + 'h-2.5', + 'w-2.5', + 'dark:hover:text-primary-200', + 'focus-visible:ring-offset-2', // Sparkline — bar sizing, gaps, and hover tints (bg-primary-500/40 and // focus-visible:ring-offset-1 are already safelisted above) 'min-w-[3px]', diff --git a/tsup.config.ts b/tsup.config.ts index da5d3c88..68c7c936 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -34,6 +34,8 @@ export default defineConfig({ 'src/components/CustomizableDashboard/index.ts', 'components/DateInput/index': 'src/components/DateInput/index.ts', 'components/Dropdown/index': 'src/components/Dropdown/index.ts', + 'components/FilterSummaryBar/index': + 'src/components/FilterSummaryBar/index.ts', 'components/FloatingWindow/index': 'src/components/FloatingWindow/index.ts', 'components/FreshnessBadge/index': 'src/components/FreshnessBadge/index.ts', 'components/GlossaryTooltip/index': 'src/components/GlossaryTooltip/index.ts',