diff --git a/.github/workflows/build_check.yaml b/.github/workflows/build_check.yaml index 8d581ad6..bb57936b 100644 --- a/.github/workflows/build_check.yaml +++ b/.github/workflows/build_check.yaml @@ -26,3 +26,10 @@ jobs: - name: Run build run: npm run build + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + timeout-minutes: 30 + run: npm run test:playwright diff --git a/.gitignore b/.gitignore index 78c6465b..1ea2873f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ # testing /coverage +/test-results/ +/playwright-report/ +/playwright/.cache/ # production /build diff --git a/package-lock.json b/package-lock.json index 5b953afe..6ea3422c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@gisce/react-formiga-components", - "version": "1.19.1", + "version": "1.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gisce/react-formiga-components", - "version": "1.19.1", + "version": "1.20.0", "license": "MIT", "dependencies": { "@ant-design/icons": "^6.0.0", @@ -15,9 +15,11 @@ "classnames": "^2.5.1", "dompurify": "^3.0.11", "file-type-buffer-browser": "git+ssh://git@github.com/mguellsegarra/file-type-buffer-browser.git", + "imask": "^7.6.0", "interweave": "^13.0.0", "react": "18.2.0", "react-dom": "18.2.0", + "react-imask": "^7.6.0", "styled-components": "5.3.5", "use-deep-compare": "^1.2.1", "validator": "^13.6.0" @@ -25,6 +27,7 @@ "devDependencies": { "@commitlint/cli": "^18.4.3", "@gisce/commitlint-rules": "1.1.0", + "@playwright/test": "^1.57.0", "@semantic-release/exec": "6.0.3", "@semantic-release/git": "10.0.1", "@semantic-release/npm": "10.0.4", @@ -286,7 +289,6 @@ "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz", "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -2252,6 +2254,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz", + "integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.43.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -3641,7 +3655,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.0.2.tgz", "integrity": "sha512-cZUy1gUvd4vttMic7C0lwPed8IYXWYp8kHIMatyhY8t8n3Cpw2ILczkV5pGMPqef7v0bLo0pOHrEHarsau2Ydg==", "dev": true, - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.0.0", @@ -3784,6 +3797,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -4685,7 +4714,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.9.1", @@ -4699,7 +4729,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.9.1", @@ -4712,7 +4743,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.9.1", @@ -4726,7 +4758,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.9.1", @@ -4740,7 +4773,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.9.1", @@ -4754,7 +4788,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.9.1", @@ -4768,7 +4803,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.9.1", @@ -4782,7 +4818,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.9.1", @@ -4796,7 +4833,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.9.1", @@ -4810,7 +4848,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.9.1", @@ -4824,7 +4863,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.9.1", @@ -4838,7 +4878,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.9.1", @@ -4852,7 +4893,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rushstack/node-core-library": { "version": "3.62.0", @@ -7249,7 +7291,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", "integrity": "sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -7610,7 +7651,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.3.tgz", "integrity": "sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -7671,7 +7711,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", "integrity": "sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==", "dev": true, - "peer": true, "dependencies": { "@types/react": "*" } @@ -7799,7 +7838,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.0.tgz", "integrity": "sha512-p0QgrEyrxAWBecR56gyn3wkG15TJdI//eetInP3zYRewDh0XS+DhB3VUAd3QqvziFsfaQIoIuZMxZRB7vXYaYw==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.59.0", @@ -7834,7 +7872,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.0.tgz", "integrity": "sha512-qK9TZ70eJtjojSUMrrEwA9ZDQ4N0e/AuoOIgXuNBorXYcBDk397D2r5MIe1B3cok/oCtdNC5j+lUUpVB+Dpb+w==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.59.0", "@typescript-eslint/types": "5.59.0", @@ -8269,7 +8306,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9097,7 +9133,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -10185,6 +10220,17 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-js-pure": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.47.0.tgz", + "integrity": "sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -10196,7 +10242,6 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, - "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -10319,7 +10364,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "devOptional": true, - "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -10343,8 +10387,7 @@ "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "peer": true + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" }, "node_modules/de-indent": { "version": "1.0.2", @@ -11135,7 +11178,6 @@ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11236,7 +11278,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.51.0.tgz", "integrity": "sha512-2WuxRZBrlwnXi+/vFSJyjMqrNjtJqiasMzehF0shoLaW7DzS3/9Yvrmq5JiT66+pNjiX4UBnLDiKHcWAr/OInA==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -11436,7 +11477,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.28.0.tgz", "integrity": "sha512-B8s/n+ZluN7sxj9eUf7/pRFERX0r5bnFA2dCaLHy2ZeaQEAz0k+ZZkFWRFHJAqxfxQDx6KLv9LeIki7cFdwW+Q==", "dev": true, - "peer": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.findlastindex": "^1.2.2", @@ -11499,7 +11539,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.6.0.tgz", "integrity": "sha512-Hd/F7wz4Mj44Jp0H6Jtty13NcE69GNTY0rVlgTIj1XBnGGVI6UTdDrpE6vqu3AHo07bygq/N+7OH/lgz1emUJw==", "dev": true, - "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -11525,7 +11564,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz", "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==", "dev": true, - "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -12975,7 +13013,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -13479,6 +13516,18 @@ "node": ">= 4" } }, + "node_modules/imask": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/imask/-/imask-7.6.1.tgz", + "integrity": "sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg==", + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs3": "^7.24.4" + }, + "engines": { + "npm": ">=4.0.0" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -15257,7 +15306,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz", "integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==", "dev": true, - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -19059,7 +19107,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -19821,6 +19868,53 @@ "node": ">=10" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/polished": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz", @@ -19972,7 +20066,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -19982,8 +20075,7 @@ "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proto-list": { "version": "1.2.4", @@ -20804,7 +20896,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -20892,7 +20983,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -20922,6 +21012,22 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, + "node_modules/react-imask": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/react-imask/-/react-imask-7.6.1.tgz", + "integrity": "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA==", + "license": "MIT", + "dependencies": { + "imask": "^7.6.1", + "prop-types": "^15.8.1" + }, + "engines": { + "npm": ">=4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -21725,7 +21831,6 @@ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-21.0.7.tgz", "integrity": "sha512-peRDSXN+hF8EFSKzze90ff/EnAmgITHQ/a3SZpRV3479ny0BIZWEJ33uX6/GlOSKdaSxo9hVRDyv2/u2MuF+Bw==", "dev": true, - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^10.0.0", "@semantic-release/error": "^4.0.0", @@ -22810,7 +22915,6 @@ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.5.tgz", "integrity": "sha512-ndETJ9RKaaL6q41B69WudeqLzOpY1A/ET/glXkNZ2T7dPjPqpPCXXQjDFYZWwNnE5co0wX+gTCqx9mfxTmSIPg==", "hasInstallScript": true, - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.0.0", "@babel/traverse": "^7.4.5", @@ -23487,7 +23591,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23873,7 +23976,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", diff --git a/package.json b/package.json index 261064c5..7143cc2c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "storybook", "library" ], - "version": "1.19.1", + "version": "1.20.0", "module": "./dist/react-formiga-components.es.js", "types": "./dist/index.d.ts", "exports": { @@ -39,7 +39,10 @@ "check": "lint-staged", "analyze": "vite-bundle-visualizer", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "test:playwright": "playwright test", + "test:playwright:ui": "playwright test --ui", + "test:playwright:headed": "playwright test --headed" }, "dependencies": { "@ant-design/icons": "^6.0.0", @@ -48,9 +51,11 @@ "classnames": "^2.5.1", "dompurify": "^3.0.11", "file-type-buffer-browser": "git+ssh://git@github.com/mguellsegarra/file-type-buffer-browser.git", + "imask": "^7.6.0", "interweave": "^13.0.0", "react": "18.2.0", "react-dom": "18.2.0", + "react-imask": "^7.6.0", "styled-components": "5.3.5", "use-deep-compare": "^1.2.1", "validator": "^13.6.0" @@ -66,6 +71,7 @@ "devDependencies": { "@commitlint/cli": "^18.4.3", "@gisce/commitlint-rules": "1.1.0", + "@playwright/test": "^1.57.0", "@semantic-release/exec": "6.0.3", "@semantic-release/git": "10.0.1", "@semantic-release/npm": "10.0.4", diff --git a/playwright-tests/DateInput.spec.ts b/playwright-tests/DateInput.spec.ts new file mode 100644 index 00000000..b88dc520 --- /dev/null +++ b/playwright-tests/DateInput.spec.ts @@ -0,0 +1,707 @@ +import { test, expect, Page } from "@playwright/test"; + +/** + * DateInput Component Test Suite + * + * This suite tests the DateInput component through Storybook stories. + * It covers: + * - Basic rendering and value display + * - Required and ReadOnly states + * - Invalid date handling + * - Date-only vs DateTime modes + * - Timezone handling (Madrid, Tokyo, UTC) + * - DST edge cases + * - User interactions + * + * Format reference: + * - Internal: "YYYY-MM-DD" (date) or "YYYY-MM-DD HH:mm:ss" (datetime) + * - Display: "DD/MM/YYYY" (date) or "DD/MM/YYYY HH:mm:ss" (datetime) + */ + +// Helper to navigate to a story and wait for it to load +async function goToStory(page: Page, storyId: string) { + await page.goto(`/iframe.html?id=${storyId}&viewMode=story`); + await page.waitForSelector('[data-testid="storybook-root"], #storybook-root, .sb-show-main', { + state: "visible", + timeout: 10000, + }); + // Wait for antd picker input to be fully rendered + await page.waitForSelector(".ant-picker-input input", { + state: "visible", + timeout: 5000, + }); +} + +// Helper to get the input element - throws if not found +async function getInput(page: Page) { + const input = page.locator(".ant-picker-input input").first(); + await expect(input).toBeVisible({ timeout: 5000 }); + return input; +} + +// Helper to get debug value from story - throws if not found +async function getDebugValue(page: Page): Promise { + const debugElement = page.locator("pre").filter({ hasText: "String value:" }); + await expect(debugElement).toBeVisible({ timeout: 5000 }); + + const debugText = await debugElement.textContent(); + expect(debugText).not.toBeNull(); + + const match = debugText!.match(/String value:\s*(.+)/); + expect(match).not.toBeNull(); + + return match![1].trim(); +} + +test.describe("DateInput Component", () => { + test.describe("Basic Rendering", () => { + test("renders with datetime value and displays correct format", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--basic"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // Value "2024-03-10 14:30:00" should display as "10/03/2024 14:30:00" + expect(displayValue).toBe("10/03/2024 14:30:00"); + + // Verify debug shows the internal format + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2024-03-10 14:30:00"); + }); + + test("renders date-only value without time", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--date-only"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // Value "2024-03-10" should display as "10/03/2024" + expect(displayValue).toBe("10/03/2024"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2024-03-10"); + }); + + test("renders empty when no value provided", async ({ page }) => { + // Navigate to basic and clear the value + await goToStory(page, "components-widgets-date-dateinput--basic"); + const input = await getInput(page); + + // Click the clear button - must be visible and clickable + await page.locator(".ant-picker").hover(); + const clearBtn = page.locator(".ant-picker-clear"); + await expect(clearBtn).toBeVisible({ timeout: 3000 }); + await clearBtn.click(); + + // Wait for input to be cleared (auto-waits) + await expect(input).toHaveValue(""); + + // Debug should show empty value (either "String value: " or "String value: undefined") + const debugElement = page.locator("pre").filter({ hasText: "String value:" }); + const debugText = await debugElement.textContent(); + expect(debugText).not.toBeNull(); + // Value should be empty or undefined after clearing + const valueMatch = debugText!.match(/String value:\s*(.*)/); + expect(valueMatch).not.toBeNull(); + const value = valueMatch![1].trim(); + expect(value === "" || value === "undefined").toBe(true); + }); + }); + + test.describe("Required State", () => { + test("displays distinct background when required", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--required"); + + const picker = page.locator(".ant-picker").first(); + await expect(picker).toBeVisible(); + + const backgroundColor = await picker.evaluate((el) => { + return window.getComputedStyle(el).backgroundColor; + }); + + // Required fields should have a colored background (not white or transparent) + expect(backgroundColor).not.toBe("rgb(255, 255, 255)"); + expect(backgroundColor).not.toBe("rgba(0, 0, 0, 0)"); + // Verify it's a valid RGB color + expect(backgroundColor).toMatch(/^rgb\(\d{1,3},\s*\d{1,3},\s*\d{1,3}\)$/); + }); + + test("required field still accepts input", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--required"); + + const input = await getInput(page); + const isEnabled = await input.isEnabled(); + expect(isEnabled).toBe(true); + + // Also verify the picker is not disabled + const picker = page.locator(".ant-picker").first(); + const isDisabled = await picker.evaluate((el) => + el.classList.contains("ant-picker-disabled") + ); + expect(isDisabled).toBe(false); + }); + }); + + test.describe("ReadOnly State", () => { + test("input is disabled when readOnly", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--read-only"); + + const picker = page.locator(".ant-picker").first(); + await expect(picker).toBeVisible(); + + const isDisabled = await picker.evaluate((el) => + el.classList.contains("ant-picker-disabled") + ); + expect(isDisabled).toBe(true); + }); + + test("cannot open calendar when readOnly", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--read-only"); + + const picker = page.locator(".ant-picker").first(); + await expect(picker).toBeVisible(); + await picker.click(); + + // Verify dropdown does NOT appear (toBeHidden auto-waits) + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeHidden(); + }); + }); + + test.describe("Invalid Date Handling", () => { + test("shows error state for invalid date value", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--invalid-date"); + + // Wait for error state to be applied + const picker = page.locator(".ant-picker.ant-picker-status-error").first(); + await expect(picker).toBeVisible({ timeout: 5000 }); + + const hasError = await picker.evaluate((el) => + el.classList.contains("ant-picker-status-error") + ); + expect(hasError).toBe(true); + }); + + test("displays error tooltip for invalid date", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--invalid-date"); + + // Error tooltip should be visible (auto-waits) + const tooltip = page.locator(".ant-tooltip-inner"); + await expect(tooltip).toBeVisible({ timeout: 5000 }); + + const tooltipText = await tooltip.textContent(); + expect(tooltipText).not.toBeNull(); + expect(tooltipText!.toLowerCase()).toContain("invalid"); + }); + }); + + test.describe("Timezone Handling", () => { + test("Europe/Madrid timezone displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--timezone-in-ooui-madrid"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-05-26 12:00:00" in Madrid should display as "26/05/2025 12:00:00" + expect(displayValue).toBe("26/05/2025 12:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-05-26 12:00:00"); + }); + + test("Asia/Tokyo timezone displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--timezone-in-ooui-tokyo"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-05-26 21:00:00" in Tokyo should display as "26/05/2025 21:00:00" + expect(displayValue).toBe("26/05/2025 21:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-05-26 21:00:00"); + }); + + test("UTC timezone displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--timezone-in-ooui-utc"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-05-26 12:00:00" in UTC should display as "26/05/2025 12:00:00" + expect(displayValue).toBe("26/05/2025 12:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-05-26 12:00:00"); + }); + }); + + test.describe("DST Edge Cases - Madrid", () => { + test("handles time just before DST starts (spring forward)", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--dst-start-madrid"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-03-30 01:59:59" should display correctly + expect(displayValue).toBe("30/03/2025 01:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-03-30 01:59:59"); + }); + + test("handles time just before DST ends (fall back)", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--dst-end-madrid"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-10-26 02:59:59" should display correctly + expect(displayValue).toBe("26/10/2025 02:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-10-26 02:59:59"); + }); + + test("handles non-existent hour during spring forward", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--dst-madrid-spring-forward"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-03-30 02:00:00" - this hour doesn't exist in Madrid + // dayjs adjusts it to 03:00:00 + expect(displayValue).toBe("30/03/2025 03:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-03-30 02:00:00"); + }); + + test("handles ambiguous hour during fall back (first occurrence)", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--dst-madrid-fall-back-first"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-10-26 02:00:00" - this hour occurs twice + expect(displayValue).toBe("26/10/2025 02:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-10-26 02:00:00"); + }); + + test("handles ambiguous hour during fall back (second occurrence)", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--dst-madrid-fall-back-second"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // Same value, second occurrence + expect(displayValue).toBe("26/10/2025 02:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-10-26 02:00:00"); + }); + }); + + test.describe("DST Edge Cases - UTC Reference", () => { + test("UTC reference time displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--utc-reference"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("30/03/2025 00:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-03-30 00:59:59"); + }); + + test("UTC during Madrid spring forward", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--dst-utc-spring-forward"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("30/03/2025 01:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-03-30 01:00:00"); + }); + + test("UTC during Madrid fall back", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--dst-utc-fall-back"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/10/2025 01:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-10-26 01:00:00"); + }); + }); + + test.describe("UTC Edge Cases", () => { + test("specific UTC time displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--specific-utc-time"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/03/2023 02:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 02:00:00"); + }); + + test("UTC to Madrid DST transition", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--utc-to-madrid-dst"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // During DST spring forward in Madrid (March 26, 2023), 02:00 doesn't exist + // The component shows 03:00 because dayjs adjusts to valid time + expect(displayValue).toBe("26/03/2023 03:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 02:00:00"); + }); + + test("UTC midnight transition", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--utc-midnight-transition"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/03/2023 00:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 00:00:00"); + }); + + test("UTC to Tokyo shows next day", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--utc-to-tokyo-next-day"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2023-03-26 15:00:00" in Tokyo timezone + expect(displayValue).toBe("26/03/2023 15:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 15:00:00"); + }); + + test("UTC last second of day", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--utc-last-second"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/03/2023 23:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 23:59:59"); + }); + }); + + test.describe("User Interactions", () => { + test("opens calendar on click", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--basic"); + + const picker = page.locator(".ant-picker").first(); + await expect(picker).toBeVisible(); + await picker.click(); + + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + }); + + test("selects date from calendar", async ({ page }) => { + // Use date-only story since DateInput hides the OK button footer + await goToStory(page, "components-widgets-date-dateinput--date-only"); + + // Store initial value + const input = await getInput(page); + const initialValue = await input.inputValue(); + expect(initialValue).toBe("10/03/2024"); + + // Open calendar + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + // Click on day 15 in the calendar + const day15 = page.locator(".ant-picker-cell-inner").filter({ hasText: /^15$/ }).first(); + await expect(day15).toBeVisible({ timeout: 3000 }); + await day15.click(); + + // Date-only mode auto-confirms on selection (no OK button needed) + // Wait for picker to close + await page.waitForTimeout(500); + + // Value should have changed to the 15th + const newDisplayValue = await input.inputValue(); + expect(newDisplayValue).toContain("15/03/2024"); + + // Verify debug value also changed + const debugValue = await getDebugValue(page); + expect(debugValue).toContain("2024-03-15"); + }); + + test("clears value with clear button", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--basic"); + + // Verify initial value exists + const input = await getInput(page); + const initialDebugValue = await getDebugValue(page); + expect(initialDebugValue).toBe("2024-03-10 14:30:00"); + + // Hover and click clear + await page.locator(".ant-picker").hover(); + const clearBtn = page.locator(".ant-picker-clear"); + await expect(clearBtn).toBeVisible({ timeout: 3000 }); + await clearBtn.click(); + + // Wait for input to be cleared + await expect(input).toHaveValue(""); + + // Verify debug shows empty value + const debugElement = page.locator("pre").filter({ hasText: "String value:" }); + const debugText = await debugElement.textContent(); + expect(debugText).not.toBeNull(); + // Value should be empty or undefined after clearing + const valueMatch = debugText!.match(/String value:\s*(.*)/); + expect(valueMatch).not.toBeNull(); + const value = valueMatch![1].trim(); + expect(value === "" || value === "undefined").toBe(true); + }); + + test("closes calendar on outside click", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--basic"); + + // Open calendar + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Click outside + await page.locator("body").click({ position: { x: 10, y: 10 } }); + + // Calendar should be closed + await expect(dropdown).toBeHidden({ timeout: 3000 }); + }); + + test("date-only: closes picker after selecting day", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--date-only"); + + // Click to open picker + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + // Verify picker dropdown is open + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Click on day 15 + const day15 = page.locator(".ant-picker-cell-inner").filter({ hasText: /^15$/ }).first(); + await day15.click(); + + // Picker should be closed after selecting day (date-only mode) + await expect(dropdown).toBeHidden({ timeout: 3000 }); + + // Value should be updated + const input = await getInput(page); + const inputValue = await input.inputValue(); + expect(inputValue).toContain("15"); + }); + }); + + test.describe("Value Format Consistency", () => { + const testCases = [ + { + storyId: "components-widgets-date-dateinput--basic", + internal: "2024-03-10 14:30:00", + display: "10/03/2024 14:30:00", + }, + { + storyId: "components-widgets-date-dateinput--date-only", + internal: "2024-03-10", + display: "10/03/2024", + }, + { + storyId: "components-widgets-date-dateinput--timezone-in-ooui-madrid", + internal: "2025-05-26 12:00:00", + display: "26/05/2025 12:00:00", + }, + { + storyId: "components-widgets-date-dateinput--timezone-in-ooui-tokyo", + internal: "2025-05-26 21:00:00", + display: "26/05/2025 21:00:00", + }, + { + storyId: "components-widgets-date-dateinput--timezone-in-ooui-utc", + internal: "2025-05-26 12:00:00", + display: "26/05/2025 12:00:00", + }, + ]; + + for (const { storyId, internal, display } of testCases) { + const storyName = storyId.split("--")[1]; + test(`${storyName}: formats ${internal} correctly`, async ({ page }) => { + await goToStory(page, storyId); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + expect(displayValue).toBe(display); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe(internal); + }); + } + }); + + test.describe("Boundary Values", () => { + test("handles year boundaries correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--utc-last-second"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // Last second of the day + expect(displayValue).toBe("26/03/2023 23:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 23:59:59"); + }); + + test("handles midnight correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--utc-midnight-transition"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/03/2023 00:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 00:00:00"); + }); + }); + + test.describe("Locale Support", () => { + test("Spanish locale shows calendar in Spanish", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--locale-spanish"); + + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + // Wait for dropdown to be visible + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for Spanish month name (marzo = March) in the header + // The date is 2024-03-10, so we should see "marzo" or "mar" + const header = page.locator(".ant-picker-header-view").first(); + await expect(header).toBeVisible(); + const headerText = await header.textContent(); + + // Spanish month names or abbreviations + expect(headerText?.toLowerCase()).toMatch(/mar|marzo/); + }); + + test("Catalan locale shows calendar in Catalan", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--locale-catalan"); + + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + // Wait for dropdown to be visible + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for Catalan month name (març = March) in the header + const header = page.locator(".ant-picker-header-view").first(); + await expect(header).toBeVisible(); + const headerText = await header.textContent(); + + // Catalan month names or abbreviations + expect(headerText?.toLowerCase()).toMatch(/mar|març/); + }); + + test("English locale shows calendar in English", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--locale-english"); + + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + // Wait for dropdown to be visible + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for English month name (Mar or March) in the header + const header = page.locator(".ant-picker-header-view").first(); + await expect(header).toBeVisible(); + const headerText = await header.textContent(); + + // English month names + expect(headerText?.toLowerCase()).toMatch(/mar|march/); + }); + + test("Spanish locale shows Spanish day names in calendar", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--locale-spanish"); + + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for Spanish day abbreviations (lu, ma, mi, ju, vi, sá, do) + const dayHeaders = page.locator(".ant-picker-content th"); + const dayTexts = await dayHeaders.allTextContents(); + const dayString = dayTexts.join("").toLowerCase(); + + // Spanish uses "lu" for Monday (lunes) + expect(dayString).toMatch(/lu|ma|mi|ju|vi/); + }); + + test("Catalan locale shows Catalan day names in calendar", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--locale-catalan"); + + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for Catalan day abbreviations (dl, dt, dc, dj, dv, ds, dg) + const dayHeaders = page.locator(".ant-picker-content th"); + const dayTexts = await dayHeaders.allTextContents(); + const dayString = dayTexts.join("").toLowerCase(); + + // Catalan uses "dl" for Monday (dilluns) + expect(dayString).toMatch(/dl|dt|dc|dj|dv/); + }); + }); + + test.describe("Picker Footer Visibility", () => { + test("picker dropdown hides OK button footer via custom popup class", async ({ page }) => { + // DateInput hides the picker footer (OK button) for cleaner UX + await goToStory(page, "components-widgets-date-dateinput--basic"); + + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + // DateInput uses "date-input-picker-dropdown" popupClassName + const dropdown = page.locator(".date-input-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // The footer should be hidden (display: none via createGlobalStyle) + const footer = page.locator(".date-input-picker-dropdown .ant-picker-footer"); + await expect(footer).toHaveCSS("display", "none"); + }); + }); +}); diff --git a/playwright-tests/DateMaskedInput.spec.ts b/playwright-tests/DateMaskedInput.spec.ts new file mode 100644 index 00000000..cefd64c3 --- /dev/null +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -0,0 +1,1421 @@ +import { test, expect, Page } from "@playwright/test"; + +/** + * DateMaskedInput Component Test Suite + * + * This suite tests the DateMaskedInput component through Storybook stories. + * It covers: + * - Basic rendering and value display + * - Required and ReadOnly states + * - Invalid date handling + * - Date-only vs DateTime modes + * - Timezone handling (Madrid, Tokyo, UTC) + * - DST edge cases + * - User interactions + * + * Format reference: + * - Internal: "YYYY-MM-DD" (date) or "YYYY-MM-DD HH:mm:ss" (datetime) + * - Display: "DD/MM/YYYY" (date) or "DD/MM/YYYY HH:mm:ss" (datetime) + */ + +// Helper to navigate to a story and wait for it to load +async function goToStory(page: Page, storyId: string) { + await page.goto(`/iframe.html?id=${storyId}&viewMode=story`); + await page.waitForSelector('[data-testid="storybook-root"], #storybook-root, .sb-show-main', { + state: "visible", + timeout: 10000, + }); + // Wait for antd picker input to be fully rendered + await page.waitForSelector(".ant-picker-input input", { + state: "visible", + timeout: 5000, + }); +} + +// Helper to get the input element - throws if not found +async function getInput(page: Page) { + const input = page.locator(".ant-picker-input input").first(); + await expect(input).toBeVisible({ timeout: 5000 }); + return input; +} + +// Helper to get debug value from story - throws if not found +async function getDebugValue(page: Page): Promise { + const debugElement = page.locator("pre").filter({ hasText: "String value:" }); + await expect(debugElement).toBeVisible({ timeout: 5000 }); + + const debugText = await debugElement.textContent(); + expect(debugText).not.toBeNull(); + + const match = debugText!.match(/String value:\s*(.+)/); + expect(match).not.toBeNull(); + + return match![1].trim(); +} + +test.describe("DateMaskedInput Component", () => { + test.describe("Basic Rendering", () => { + test("renders with datetime value and displays correct format", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // Value "2024-03-10 14:30:00" should display as "10/03/2024 14:30:00" + expect(displayValue).toBe("10/03/2024 14:30:00"); + + // Verify debug shows the internal format + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2024-03-10 14:30:00"); + }); + + test("renders date-only value without time", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--date-only"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // Value "2024-03-10" should display as "10/03/2024" + expect(displayValue).toBe("10/03/2024"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2024-03-10"); + }); + + test("renders empty when no value provided", async ({ page }) => { + // Navigate to basic and clear the value + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + const input = await getInput(page); + + // Click the clear button - must be visible and clickable + // Use .first() since there may be a hidden antd picker with same class + await page.locator(".ant-picker").first().hover(); + const clearBtn = page.locator(".ant-picker-clear").first(); + await expect(clearBtn).toBeVisible({ timeout: 3000 }); + await clearBtn.click(); + + // Wait for input to be cleared (auto-waits) + await expect(input).toHaveValue(""); + + // Debug should show empty value (either "String value: " or "String value: undefined") + const debugElement = page.locator("pre").filter({ hasText: "String value:" }); + const debugText = await debugElement.textContent(); + expect(debugText).not.toBeNull(); + // Value should be empty or undefined after clearing + const valueMatch = debugText!.match(/String value:\s*(.*)/); + expect(valueMatch).not.toBeNull(); + const value = valueMatch![1].trim(); + expect(value === "" || value === "undefined").toBe(true); + }); + }); + + test.describe("Required State", () => { + test("displays distinct background when required", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--required"); + + const picker = page.locator(".ant-picker").first(); + await expect(picker).toBeVisible(); + + const backgroundColor = await picker.evaluate((el) => { + return window.getComputedStyle(el).backgroundColor; + }); + + // Required fields should have a colored background (not white or transparent) + expect(backgroundColor).not.toBe("rgb(255, 255, 255)"); + expect(backgroundColor).not.toBe("rgba(0, 0, 0, 0)"); + // Verify it's a valid RGB color + expect(backgroundColor).toMatch(/^rgb\(\d{1,3},\s*\d{1,3},\s*\d{1,3}\)$/); + }); + + test("required field still accepts input", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--required"); + + const input = await getInput(page); + const isEnabled = await input.isEnabled(); + expect(isEnabled).toBe(true); + + // Also verify the picker is not disabled + const picker = page.locator(".ant-picker").first(); + const isDisabled = await picker.evaluate((el) => + el.classList.contains("ant-picker-disabled") + ); + expect(isDisabled).toBe(false); + }); + }); + + test.describe("ReadOnly State", () => { + test("input is disabled when readOnly", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--read-only"); + + const picker = page.locator(".ant-picker").first(); + await expect(picker).toBeVisible(); + + const isDisabled = await picker.evaluate((el) => + el.classList.contains("ant-picker-disabled") + ); + expect(isDisabled).toBe(true); + }); + + test("cannot open calendar when readOnly", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--read-only"); + + const picker = page.locator(".ant-picker").first(); + await expect(picker).toBeVisible(); + await picker.click(); + await page.waitForTimeout(300); + + // Verify dropdown does NOT appear + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeHidden(); + }); + }); + + test.describe("Invalid Date Handling", () => { + test("shows error state for invalid date value", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--invalid-date"); + await page.waitForTimeout(500); + + // Check for error styling (red border) + const picker = page.locator(".ant-picker").first(); + await expect(picker).toBeVisible(); + + const hasError = await picker.evaluate((el) => + el.classList.contains("ant-picker-status-error") + ); + expect(hasError).toBe(true); + }); + + test("displays error tooltip for invalid date", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--invalid-date"); + await page.waitForTimeout(500); + + // Error tooltip should be visible + const tooltip = page.locator(".ant-tooltip-inner"); + await expect(tooltip).toBeVisible({ timeout: 3000 }); + + const tooltipText = await tooltip.textContent(); + expect(tooltipText).not.toBeNull(); + expect(tooltipText!.toLowerCase()).toContain("invalid"); + }); + }); + + test.describe("Timezone Handling", () => { + test("Europe/Madrid timezone displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--timezone-in-ooui-madrid"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-05-26 12:00:00" in Madrid should display as "26/05/2025 12:00:00" + expect(displayValue).toBe("26/05/2025 12:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-05-26 12:00:00"); + }); + + test("Asia/Tokyo timezone displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--timezone-in-ooui-tokyo"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-05-26 21:00:00" in Tokyo should display as "26/05/2025 21:00:00" + expect(displayValue).toBe("26/05/2025 21:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-05-26 21:00:00"); + }); + + test("UTC timezone displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--timezone-in-ooui-utc"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-05-26 12:00:00" in UTC should display as "26/05/2025 12:00:00" + expect(displayValue).toBe("26/05/2025 12:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-05-26 12:00:00"); + }); + }); + + test.describe("DST Edge Cases - Madrid", () => { + test("handles time just before DST starts (spring forward)", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--dst-start-madrid"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-03-30 01:59:59" should display correctly + expect(displayValue).toBe("30/03/2025 01:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-03-30 01:59:59"); + }); + + test("handles time just before DST ends (fall back)", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--dst-end-madrid"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-10-26 02:59:59" should display correctly + expect(displayValue).toBe("26/10/2025 02:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-10-26 02:59:59"); + }); + + test("handles non-existent hour during spring forward", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--dst-madrid-spring-forward"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-03-30 02:00:00" - this hour doesn't exist in Madrid + // dayjs adjusts it to 03:00:00 + expect(displayValue).toBe("30/03/2025 03:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-03-30 02:00:00"); + }); + + test("handles ambiguous hour during fall back (first occurrence)", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--dst-madrid-fall-back-first"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2025-10-26 02:00:00" - this hour occurs twice + expect(displayValue).toBe("26/10/2025 02:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-10-26 02:00:00"); + }); + + test("handles ambiguous hour during fall back (second occurrence)", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--dst-madrid-fall-back-second"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // Same value, second occurrence + expect(displayValue).toBe("26/10/2025 02:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-10-26 02:00:00"); + }); + }); + + test.describe("DST Edge Cases - UTC Reference", () => { + test("UTC reference time displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--utc-reference"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("30/03/2025 00:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-03-30 00:59:59"); + }); + + test("UTC during Madrid spring forward", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--dst-utc-spring-forward"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("30/03/2025 01:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-03-30 01:00:00"); + }); + + test("UTC during Madrid fall back", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--dst-utc-fall-back"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/10/2025 01:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2025-10-26 01:00:00"); + }); + }); + + test.describe("UTC Edge Cases", () => { + test("specific UTC time displays correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--specific-utc-time"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/03/2023 02:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 02:00:00"); + }); + + test("UTC to Madrid DST transition", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--utc-to-madrid-dst"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // During DST spring forward in Madrid (March 26, 2023), 02:00 doesn't exist + // The component shows 03:00 because dayjs adjusts to valid time + expect(displayValue).toBe("26/03/2023 03:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 02:00:00"); + }); + + test("UTC midnight transition", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--utc-midnight-transition"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/03/2023 00:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 00:00:00"); + }); + + test("UTC to Tokyo shows next day", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--utc-to-tokyo-next-day"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // "2023-03-26 15:00:00" in Tokyo timezone + expect(displayValue).toBe("26/03/2023 15:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 15:00:00"); + }); + + test("UTC last second of day", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--utc-last-second"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/03/2023 23:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 23:59:59"); + }); + }); + + test.describe("User Interactions", () => { + test("opens calendar on click", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const picker = page.locator(".ant-picker").first(); + await expect(picker).toBeVisible(); + await picker.click(); + + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + }); + + test("selects date from calendar", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + // Store initial value + const input = await getInput(page); + const initialValue = await input.inputValue(); + expect(initialValue).toBe("10/03/2024 14:30:00"); + + // Open calendar + const picker = page.locator(".ant-picker").first(); + await picker.click(); + await page.waitForTimeout(300); + + // Click on day 15 in the calendar + const day15 = page.locator(".ant-picker-cell-in-view .ant-picker-cell-inner").filter({ hasText: /^15$/ }); + await expect(day15).toBeVisible({ timeout: 3000 }); + await day15.click(); + await page.waitForTimeout(300); + + // With showTime=true, antd requires clicking OK button to confirm + const okButton = page.locator(".ant-picker-ok button"); + await expect(okButton).toBeVisible({ timeout: 3000 }); + await okButton.click(); + await page.waitForTimeout(300); + + // Value should have changed to the 15th (keeping time) + const newDisplayValue = await input.inputValue(); + expect(newDisplayValue).toContain("15/03/2024"); + + // Verify debug value also changed + const debugValue = await getDebugValue(page); + expect(debugValue).toContain("2024-03-15"); + }); + + test("clears value with clear button", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + // Verify initial value exists + const input = await getInput(page); + const initialDebugValue = await getDebugValue(page); + expect(initialDebugValue).toBe("2024-03-10 14:30:00"); + + // Hover and click clear + // Use .first() since there may be a hidden antd picker with same class + await page.locator(".ant-picker").first().hover(); + const clearBtn = page.locator(".ant-picker-clear").first(); + await expect(clearBtn).toBeVisible({ timeout: 3000 }); + await clearBtn.click(); + await page.waitForTimeout(300); + + // Verify input is now empty + const inputValue = await input.inputValue(); + expect(inputValue).toBe(""); + + // Verify debug shows empty value + const debugElement = page.locator("pre").filter({ hasText: "String value:" }); + const debugText = await debugElement.textContent(); + expect(debugText).not.toBeNull(); + // Value should be empty or undefined after clearing + const valueMatch = debugText!.match(/String value:\s*(.*)/); + expect(valueMatch).not.toBeNull(); + const value = valueMatch![1].trim(); + expect(value === "" || value === "undefined").toBe(true); + }); + + test("closes calendar on outside click", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + // Open calendar + const picker = page.locator(".ant-picker").first(); + await picker.click(); + + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Click outside + await page.locator("body").click({ position: { x: 10, y: 10 } }); + + // Calendar should be closed + await expect(dropdown).toBeHidden({ timeout: 3000 }); + }); + + test("closes picker and moves focus on Tab key", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + // Focus the input which opens the picker + const input = await getInput(page); + await input.click(); + await page.waitForTimeout(300); + + // Verify picker dropdown is open + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Press Tab - should close picker and move focus out + await page.keyboard.press("Tab"); + await page.waitForTimeout(300); + + // Picker should be closed + await expect(dropdown).toBeHidden({ timeout: 3000 }); + + // Focus should NOT be on the picker panel (should have moved past the component) + const focusedElement = await page.evaluate(() => { + const el = document.activeElement; + return el?.className || ""; + }); + expect(focusedElement).not.toContain("ant-picker-panel"); + }); + + test("date-only: closes picker after selecting day", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--date-only"); + + // Click to open picker + const picker = page.locator(".ant-picker").first(); + await picker.click(); + await page.waitForTimeout(300); + + // Verify picker dropdown is open + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Click on day 15 + const day15 = page.locator(".ant-picker-cell-in-view .ant-picker-cell-inner").filter({ hasText: /^15$/ }); + await day15.click(); + await page.waitForTimeout(300); + + // Picker should be closed after selecting day (date-only mode) + await expect(dropdown).toBeHidden({ timeout: 3000 }); + + // Value should be updated + const input = await getInput(page); + const inputValue = await input.inputValue(); + expect(inputValue).toContain("15"); + }); + }); + + test.describe("Value Format Consistency", () => { + const testCases = [ + { + storyId: "components-widgets-date-datemaskedinput--basic", + internal: "2024-03-10 14:30:00", + display: "10/03/2024 14:30:00", + }, + { + storyId: "components-widgets-date-datemaskedinput--date-only", + internal: "2024-03-10", + display: "10/03/2024", + }, + { + storyId: "components-widgets-date-datemaskedinput--timezone-in-ooui-madrid", + internal: "2025-05-26 12:00:00", + display: "26/05/2025 12:00:00", + }, + { + storyId: "components-widgets-date-datemaskedinput--timezone-in-ooui-tokyo", + internal: "2025-05-26 21:00:00", + display: "26/05/2025 21:00:00", + }, + { + storyId: "components-widgets-date-datemaskedinput--timezone-in-ooui-utc", + internal: "2025-05-26 12:00:00", + display: "26/05/2025 12:00:00", + }, + ]; + + for (const { storyId, internal, display } of testCases) { + const storyName = storyId.split("--")[1]; + test(`${storyName}: formats ${internal} correctly`, async ({ page }) => { + await goToStory(page, storyId); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + expect(displayValue).toBe(display); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe(internal); + }); + } + }); + + test.describe("Boundary Values", () => { + test("handles year boundaries correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--utc-last-second"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + // Last second of the day + expect(displayValue).toBe("26/03/2023 23:59:59"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 23:59:59"); + }); + + test("handles midnight correctly", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--utc-midnight-transition"); + + const input = await getInput(page); + const displayValue = await input.inputValue(); + + expect(displayValue).toBe("26/03/2023 00:00:00"); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2023-03-26 00:00:00"); + }); + }); + + test.describe("Keyboard Behavior", () => { + test("Enter key with partial date autocompletes to full date", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + // Find the "Enter Key" input (id="test-enter") + const input = page.locator("#test-enter"); + await input.click(); + await page.waitForTimeout(200); + + // Type partial date "23" + await input.fill(""); + await input.type("23"); + await page.waitForTimeout(100); + + // Press Enter to autocomplete + await input.press("Enter"); + await page.waitForTimeout(300); + + // Should have autocompleted to a full date with day 23 + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^23\/\d{2}\/\d{4}$/); + }); + + test("Enter key with empty input autocompletes to current date", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + const input = page.locator("#test-enter"); + await input.click(); + await page.waitForTimeout(200); + + // Clear and press Enter on empty input + await input.fill(""); + await page.waitForTimeout(100); + await input.press("Enter"); + await page.waitForTimeout(300); + + // Should have autocompleted to current date + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + }); + + test("Blur with partial date autocompletes", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + // Find the "Blur" input (id="test-blur") + const input = page.locator("#test-blur"); + await input.click(); + await page.waitForTimeout(200); + + // Type partial date "10" + await input.fill(""); + await input.type("10"); + await page.waitForTimeout(100); + + // Click outside to blur + await page.locator("body").click({ position: { x: 10, y: 10 } }); + await page.waitForTimeout(300); + + // Should have autocompleted to a full date with day 10 + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^10\/\d{2}\/\d{4}$/); + }); + + test("Double-click with partial date autocompletes", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + // Find the "Double-click" input (id="test-dblclick") + const input = page.locator("#test-dblclick"); + await input.click(); + await page.waitForTimeout(200); + + // Type partial date "25/12" + await input.fill(""); + await input.type("25/12"); + await page.waitForTimeout(100); + + // Double-click to autocomplete + await input.dblclick(); + await page.waitForTimeout(300); + + // Should have autocompleted to a full date with day 25, month 12 + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^25\/12\/\d{4}$/); + }); + + test("Double-click with empty input autocompletes to current date", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + // Find the "Double-click" input + const input = page.locator("#test-dblclick"); + await input.click(); + await page.waitForTimeout(200); + + // Clear the input + await input.fill(""); + await page.waitForTimeout(100); + + // Double-click on empty input + await input.dblclick(); + await page.waitForTimeout(300); + + // Should have autocompleted to current date + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + }); + + test("Backspace can clear entire value", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + // Use the "Clear value" input which has a pre-set value + const input = page.locator("#test-clear"); + await input.click(); + await page.waitForTimeout(200); + + // Select all (cross-platform: Control+a on Linux/Windows, Meta+a on Mac) + // Use triple-click to select all text as a reliable cross-platform method + await input.click({ clickCount: 3 }); + await page.waitForTimeout(100); + await input.press("Backspace"); + await page.waitForTimeout(100); + + // Click outside to blur and trigger commit + await page.locator("body").click({ position: { x: 10, y: 10 } }); + await page.waitForTimeout(300); + + // Value should be cleared, not autocompleted + const inputValue = await input.inputValue(); + expect(inputValue).toBe(""); + }); + + test("Tab commits value, closes picker, and moves to next input", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + // Focus the first input + const input1 = page.locator("#test-enter"); + await input1.click(); + await page.waitForTimeout(200); + + // Type partial date + await input1.fill(""); + await input1.type("15"); + await page.waitForTimeout(100); + + // Press Tab + await input1.press("Tab"); + await page.waitForTimeout(300); + + // Value should be committed (autocompleted) + const inputValue = await input1.inputValue(); + expect(inputValue).toMatch(/^15\/\d{2}\/\d{4}$/); + + // Focus should have moved to next input (test-blur) + // Note: The second input's picker will open because it received focus + const input2 = page.locator("#test-blur"); + await expect(input2).toBeFocused({ timeout: 3000 }); + }); + + test("Escape commits value and closes picker", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + const input = page.locator("#test-enter"); + await input.click(); + await page.waitForTimeout(200); + + // Verify picker is open + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Type partial date + await input.fill(""); + await input.type("15"); + await page.waitForTimeout(100); + + // Press Escape + await input.press("Escape"); + await page.waitForTimeout(300); + + // Should have committed the value + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^15\/\d{2}\/\d{4}$/); + + // Picker should be closed + await expect(dropdown).toBeHidden({ timeout: 3000 }); + }); + }); + + test.describe("Autocomplete Scenarios", () => { + test("date: partial day autocompletes with current month/year", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--autocomplete"); + + // Find first date input + const input = page.locator("#auto-date-1"); + await input.click(); + await page.waitForTimeout(200); + + // Type just day "15" + await input.fill(""); + await input.type("15"); + await input.press("Enter"); + await page.waitForTimeout(300); + + // Should show full date with current month/year + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^15\/\d{2}\/\d{4}$/); + }); + + test("date: partial day/month autocompletes with current year", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--autocomplete"); + + // Find second date input + const input = page.locator("#auto-date-2"); + await input.click(); + await page.waitForTimeout(200); + + // Type day/month "15/06" + await input.fill(""); + await input.type("15/06"); + await input.press("Enter"); + await page.waitForTimeout(300); + + // Should show full date with current year + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^15\/06\/\d{4}$/); + }); + + test("datetime: partial date+hour autocompletes with current min:sec", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--autocomplete"); + + // Find datetime input + const input = page.locator("#auto-datetime-1"); + await input.click(); + await page.waitForTimeout(200); + + // Type date + hour "15/06/2024 14" + await input.fill(""); + await input.type("15/06/2024 14"); + await input.press("Enter"); + await page.waitForTimeout(300); + + // Should show full datetime + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^15\/06\/2024 14:\d{2}:\d{2}$/); + }); + + test("time: partial hour autocompletes with zeros", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--autocomplete"); + + // Find first time input + const input = page.locator("#auto-time-1"); + await input.click(); + await page.waitForTimeout(200); + + // Type just hour "14" + await input.fill(""); + await input.type("14"); + await input.press("Enter"); + await page.waitForTimeout(300); + + // Should show full time + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^14:\d{2}:\d{2}$/); + }); + + test("time: partial hour:minute autocompletes seconds", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--autocomplete"); + + // Find second time input + const input = page.locator("#auto-time-2"); + await input.click(); + await page.waitForTimeout(200); + + // Type hour:minute "14:30" + await input.fill(""); + await input.type("14:30"); + await input.press("Enter"); + await page.waitForTimeout(300); + + // Should show full time with seconds + const inputValue = await input.inputValue(); + expect(inputValue).toMatch(/^14:30:\d{2}$/); + }); + }); + + test.describe("Separator Characters", () => { + test("typing / character moves to next date section", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + const input = page.locator("#test-enter"); + await input.click(); + await page.waitForTimeout(200); + + // Clear and type date with explicit / separators + await input.fill(""); + await input.type("10/03/2024"); + await page.waitForTimeout(100); + + // Should have the full date + const inputValue = await input.inputValue(); + expect(inputValue).toBe("10/03/2024"); + }); + + test("typing - character moves to next date section (like /)", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--keyboard-behavior"); + + const input = page.locator("#test-enter"); + await input.click(); + await page.waitForTimeout(200); + + // Clear and type date using - as separator + await input.fill(""); + await input.type("10-03-2024"); + await page.waitForTimeout(100); + + // Should have the full date (converted to / format) + const inputValue = await input.inputValue(); + expect(inputValue).toBe("10/03/2024"); + }); + + test("typing - character moves to next time section (like :)", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--autocomplete"); + + const input = page.locator("#auto-time-1"); + await input.click(); + await page.waitForTimeout(200); + + // Clear and type time using - as separator + await input.fill(""); + await input.type("14-30-00"); + await page.waitForTimeout(100); + + // Should have the full time (converted to : format) + const inputValue = await input.inputValue(); + expect(inputValue).toBe("14:30:00"); + }); + }); + + test.describe("Locale Support", () => { + test("Spanish locale shows calendar in Spanish", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--locale-spanish"); + + const input = await getInput(page); + await input.click(); + + // Wait for dropdown to be visible + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for Spanish month name (marzo = March) in the header + const header = page.locator(".ant-picker-header-view").first(); + await expect(header).toBeVisible(); + const headerText = await header.textContent(); + + // Spanish month names or abbreviations + expect(headerText?.toLowerCase()).toMatch(/mar|marzo/); + }); + + test("Catalan locale shows calendar in Catalan", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--locale-catalan"); + + const input = await getInput(page); + await input.click(); + + // Wait for dropdown to be visible + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for Catalan month name (març = March) in the header + const header = page.locator(".ant-picker-header-view").first(); + await expect(header).toBeVisible(); + const headerText = await header.textContent(); + + // Catalan month names or abbreviations + expect(headerText?.toLowerCase()).toMatch(/mar|març/); + }); + + test("English locale shows calendar in English", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--locale-english"); + + const input = await getInput(page); + await input.click(); + + // Wait for dropdown to be visible + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for English month name (Mar or March) in the header + const header = page.locator(".ant-picker-header-view").first(); + await expect(header).toBeVisible(); + const headerText = await header.textContent(); + + // English month names + expect(headerText?.toLowerCase()).toMatch(/mar|march/); + }); + + test("Spanish locale shows Spanish day names in calendar", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--locale-spanish"); + + const input = await getInput(page); + await input.click(); + + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for Spanish day abbreviations (lu, ma, mi, ju, vi, sá, do) + const dayHeaders = page.locator(".ant-picker-content th"); + const dayTexts = await dayHeaders.allTextContents(); + const dayString = dayTexts.join("").toLowerCase(); + + // Spanish uses "lu" for Monday (lunes) + expect(dayString).toMatch(/lu|ma|mi|ju|vi/); + }); + + test("Catalan locale shows Catalan day names in calendar", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--locale-catalan"); + + const input = await getInput(page); + await input.click(); + + const dropdown = page.locator(".ant-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // Check for Catalan day abbreviations (dl, dt, dc, dj, dv, ds, dg) + const dayHeaders = page.locator(".ant-picker-content th"); + const dayTexts = await dayHeaders.allTextContents(); + const dayString = dayTexts.join("").toLowerCase(); + + // Catalan uses "dl" for Monday (dilluns) + expect(dayString).toMatch(/dl|dt|dc|dj|dv/); + }); + }); + + test.describe("Picker Footer Visibility", () => { + test("picker dropdown uses custom popup class to allow CSS overrides", async ({ page }) => { + // This test verifies that DateMaskedInput uses a custom popupClassName + // that allows overriding global CSS rules (e.g., webclient hiding .ant-picker-footer) + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + await input.click(); + + // DateMaskedInput uses "date-masked-input-picker-dropdown" popupClassName + const dropdown = page.locator(".date-masked-input-picker-dropdown"); + await expect(dropdown).toBeVisible({ timeout: 3000 }); + + // The footer should be visible with display: block (via createGlobalStyle override) + // This ensures the OK button is accessible even if external CSS tries to hide it + const footer = page.locator(".date-masked-input-picker-dropdown .ant-picker-footer"); + await expect(footer).toBeVisible(); + await expect(footer).toHaveCSS("display", "block"); + }); + }); + + test.describe("Two-Way Sync - Datetime", () => { + test("clicking date cell updates input immediately", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + await expect(input).toHaveValue("10/03/2024 14:30:00"); + + const dropdown = page.locator(".ant-picker-dropdown"); + await input.click(); + await expect(dropdown).toBeVisible(); + + const day20 = page.locator(".ant-picker-cell-in-view .ant-picker-cell-inner").filter({ hasText: /^20$/ }); + await day20.click(); + + await expect(input).toHaveValue("20/03/2024 14:30:00"); + await expect(dropdown).toBeVisible(); + }); + + test("clicking hour in time panel updates input immediately", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + await expect(input).toHaveValue("10/03/2024 14:30:00"); + + const dropdown = page.locator(".ant-picker-dropdown"); + await input.click(); + await expect(dropdown).toBeVisible(); + + const hour16 = page.locator(".ant-picker-time-panel-column").first().locator("li").filter({ hasText: /^16$/ }); + await hour16.click(); + + await expect(input).toHaveValue("10/03/2024 16:30:00"); + }); + + test("clicking minute in time panel updates input immediately", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + await expect(input).toHaveValue("10/03/2024 14:30:00"); + + const dropdown = page.locator(".ant-picker-dropdown"); + await input.click(); + await expect(dropdown).toBeVisible(); + + const minute45 = page.locator(".ant-picker-time-panel-column").nth(1).locator("li").filter({ hasText: /^45$/ }); + await minute45.click(); + + // Input should update immediately + await expect(input).toHaveValue("10/03/2024 14:45:00"); + }); + + test("clicking second in time panel updates input immediately", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + await expect(input).toHaveValue("10/03/2024 14:30:00"); + + const dropdown = page.locator(".ant-picker-dropdown"); + await input.click(); + await expect(dropdown).toBeVisible(); + + const second15 = page.locator(".ant-picker-time-panel-column").nth(2).locator("li").filter({ hasText: /^15$/ }); + await second15.click(); + + await expect(input).toHaveValue("10/03/2024 14:30:15"); + }); + + test("clicking year updates input and navigates to month view", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + await expect(input).toHaveValue("10/03/2024 14:30:00"); + + const dropdown = page.locator(".ant-picker-dropdown"); + await input.click(); + await expect(dropdown).toBeVisible(); + + const yearHeader = page.locator(".ant-picker-year-btn"); + await yearHeader.click(); + + const yearPanel = page.locator(".ant-picker-year-panel"); + await expect(yearPanel).toBeVisible(); + + const year2025 = page.locator(".ant-picker-cell-inner").filter({ hasText: /^2025$/ }); + await year2025.click(); + + await expect(input).toHaveValue("10/03/2025 14:30:00"); + await expect(dropdown).toBeVisible(); + + const monthPanel = page.locator(".ant-picker-month-panel"); + await expect(monthPanel).toBeVisible(); + }); + + test("clicking month updates input and navigates to date view", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + await expect(input).toHaveValue("10/03/2024 14:30:00"); + + const dropdown = page.locator(".ant-picker-dropdown"); + await input.click(); + await expect(dropdown).toBeVisible(); + + const monthHeader = page.locator(".ant-picker-month-btn"); + await monthHeader.click(); + + const monthPanel = page.locator(".ant-picker-month-panel"); + await expect(monthPanel).toBeVisible(); + + const july = page.locator(".ant-picker-cell-inner").filter({ hasText: /^Jul$/ }); + await july.click(); + + await expect(input).toHaveValue("10/07/2024 14:30:00"); + await expect(dropdown).toBeVisible(); + + const datePanel = page.locator(".ant-picker-date-panel"); + await expect(datePanel).toBeVisible(); + }); + + test("OK button from year panel commits value", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + const dropdown = page.locator(".ant-picker-dropdown"); + + await input.click(); + await expect(dropdown).toBeVisible(); + + const yearHeader = page.locator(".ant-picker-year-btn"); + await yearHeader.click(); + + const yearPanel = page.locator(".ant-picker-year-panel"); + await expect(yearPanel).toBeVisible(); + + // Click on year 2026 + const year2026 = page.locator(".ant-picker-cell-inner").filter({ hasText: /^2026$/ }); + await year2026.click(); + + const monthPanel = page.locator(".ant-picker-month-panel"); + await expect(monthPanel).toBeVisible(); + + const okButton = page.locator(".ant-picker-ok button"); + await okButton.click(); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2026-03-10 14:30:00"); + await expect(dropdown).not.toBeVisible(); + }); + + test("OK button from month panel commits value", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + const dropdown = page.locator(".ant-picker-dropdown"); + + await input.click(); + await expect(dropdown).toBeVisible(); + + const monthHeader = page.locator(".ant-picker-month-btn"); + await monthHeader.click(); + + const monthPanel = page.locator(".ant-picker-month-panel"); + await expect(monthPanel).toBeVisible(); + + const december = page.locator(".ant-picker-cell-inner").filter({ hasText: /^Dec$/ }); + await december.click(); + + const datePanel = page.locator(".ant-picker-date-panel"); + await expect(datePanel).toBeVisible(); + + const okButton = page.locator(".ant-picker-ok button"); + await okButton.click(); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2024-12-10 14:30:00"); + await expect(dropdown).not.toBeVisible(); + }); + + test("typing updates picker selection", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--basic"); + + const input = await getInput(page); + const dropdown = page.locator(".ant-picker-dropdown"); + + await input.click(); + await expect(dropdown).toBeVisible(); + + await input.fill(""); + await input.pressSequentially("25/06/2025 10:15:30"); + + const selectedDay = page.locator(".ant-picker-cell-selected .ant-picker-cell-inner"); + await expect(selectedDay).toHaveText("25"); + + const monthBtn = page.locator(".ant-picker-month-btn"); + await expect(monthBtn).toHaveText("Jun"); + const yearBtn = page.locator(".ant-picker-year-btn"); + await expect(yearBtn).toHaveText("2025"); + }); + }); + + test.describe("Two-Way Sync - Time Only", () => { + test("clicking hour updates input immediately", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--autocomplete"); + + const timeInput = page.locator('[id="auto-time-1"]'); + const dropdown = page.locator(".ant-picker-dropdown"); + + await timeInput.click(); + await timeInput.fill(""); + await timeInput.pressSequentially("14:30:00"); + await page.keyboard.press("Enter"); + + await expect(timeInput).toHaveValue("14:30:00"); + + await timeInput.click(); + await expect(dropdown).toBeVisible(); + + const hour09 = page.locator(".ant-picker-time-panel-column").first().locator("li").filter({ hasText: /^09$/ }); + await hour09.click(); + await expect(timeInput).toHaveValue("09:30:00"); + }); + + test("clicking minute updates input immediately", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--autocomplete"); + + const timeInput = page.locator('[id="auto-time-1"]'); + const dropdown = page.locator(".ant-picker-dropdown"); + + await timeInput.click(); + await timeInput.fill(""); + await timeInput.pressSequentially("14:30:00"); + await page.keyboard.press("Enter"); + + await expect(timeInput).toHaveValue("14:30:00"); + + await timeInput.click(); + await expect(dropdown).toBeVisible(); + + const minute45 = page.locator(".ant-picker-time-panel-column").nth(1).locator("li").filter({ hasText: /^45$/ }); + await minute45.click(); + + await expect(timeInput).toHaveValue("14:45:00"); + }); + + test("OK button commits time value", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--autocomplete"); + + const timeInput = page.locator('[id="auto-time-1"]'); + const dropdown = page.locator(".ant-picker-dropdown"); + + await timeInput.click(); + await timeInput.fill(""); + await timeInput.pressSequentially("14:30:00"); + await page.keyboard.press("Enter"); + + await expect(timeInput).toHaveValue("14:30:00"); + + await timeInput.click(); + await expect(dropdown).toBeVisible(); + + const hour18 = page.locator(".ant-picker-time-panel-column").first().locator("li").filter({ hasText: /^18$/ }); + await hour18.click(); + + await expect(timeInput).toHaveValue("18:30:00"); + + const okButton = page.locator(".ant-picker-ok button"); + await okButton.click(); + + await expect(dropdown).not.toBeVisible(); + await expect(timeInput).toHaveValue("18:30:00"); + }); + }); + + test.describe("Two-Way Sync - Date Only", () => { + test("clicking date commits immediately (no OK needed)", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--date-only"); + + const input = await getInput(page); + await expect(input).toHaveValue("10/03/2024"); + + const dropdown = page.locator(".ant-picker-dropdown"); + await input.click(); + await expect(dropdown).toBeVisible(); + + const day25 = page.locator(".ant-picker-cell-in-view .ant-picker-cell-inner").filter({ hasText: /^25$/ }); + await day25.click(); + + const debugValue = await getDebugValue(page); + expect(debugValue).toBe("2024-03-25"); + await expect(dropdown).not.toBeVisible(); + }); + + test("clicking year updates input and stays in picker", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--date-only"); + + const input = await getInput(page); + const dropdown = page.locator(".ant-picker-dropdown"); + + await input.click(); + await expect(dropdown).toBeVisible(); + + const yearHeader = page.locator(".ant-picker-year-btn"); + await yearHeader.click(); + + const yearPanel = page.locator(".ant-picker-year-panel"); + await expect(yearPanel).toBeVisible(); + + const year2023 = page.locator(".ant-picker-cell-inner").filter({ hasText: /^2023$/ }); + await year2023.click(); + + await expect(input).toHaveValue("10/03/2023"); + await expect(dropdown).toBeVisible(); + }); + + test("clicking month updates input and stays in picker", async ({ page }) => { + await goToStory(page, "components-widgets-date-datemaskedinput--date-only"); + + const input = await getInput(page); + const dropdown = page.locator(".ant-picker-dropdown"); + + await input.click(); + await expect(dropdown).toBeVisible(); + + const monthHeader = page.locator(".ant-picker-month-btn"); + await monthHeader.click(); + + const monthPanel = page.locator(".ant-picker-month-panel"); + await expect(monthPanel).toBeVisible(); + + const august = page.locator(".ant-picker-cell-inner").filter({ hasText: /^Aug$/ }); + await august.click(); + + await expect(input).toHaveValue("10/08/2024"); + await expect(dropdown).toBeVisible(); + }); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..91fa7191 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright configuration for testing React components via Storybook + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: "./playwright-tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + // Base URL for Storybook + baseURL: "http://localhost:6006", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + // Run Storybook server before tests + webServer: { + command: "npm run storybook", + url: "http://localhost:6006", + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/src/components/widgets/Date/DateInput/DateInput.stories.tsx b/src/components/widgets/Date/DateInput/DateInput.stories.tsx index 140cdbc8..7064ce38 100644 --- a/src/components/widgets/Date/DateInput/DateInput.stories.tsx +++ b/src/components/widgets/Date/DateInput/DateInput.stories.tsx @@ -4,6 +4,7 @@ import { DateInput } from "./DateInput"; import { Form } from "antd"; import dayjs from "@/helpers/dayjs"; import { DateInputProps } from "./DateInput.types"; +import { LocaleContextProvider, Locale } from "@/context/LocaleContext"; // Extended props for Storybook args type StoryArgs = DateInputProps & { @@ -333,3 +334,106 @@ UTCLastSecond.args = { readOnly: false, timezone: "UTC", }; + +// ============================================ +// LOCALE STORIES +// ============================================ + +// Extended args type for locale stories +type LocaleStoryArgs = StoryArgs & { + locale: Locale; +}; + +const LocaleTemplate: StoryFn = (args) => { + const [form] = Form.useForm(); + const fieldName = args.id || "dateField"; + const [currentValue, setCurrentValue] = useState( + args.value, + ); + + useEffect(() => { + if (args.value) { + form.setFieldsValue({ [fieldName]: args.value }); + setCurrentValue(args.value); + } + }, [args.value, form, fieldName]); + + const handleChange = (value: string | null | undefined) => { + form.setFieldValue(fieldName, value); + setCurrentValue(value || undefined); + args.onChange?.(value); + }; + + return ( + +
+
{ + const newValue = form.getFieldValue(fieldName); + setCurrentValue(newValue); + }} + > + + + +
+ Debug Information: +
String value: {currentValue}
+
locale: {args.locale}
+
timezone: {args.timezone}
+
showTime: {args.showTime?.toString()}
+
+
+
+
+ ); +}; + +// Spanish locale +export const LocaleSpanish = LocaleTemplate.bind({}); +LocaleSpanish.args = { + id: "locale-spanish", + value: "2024-03-10 14:30:00", + showTime: true, + required: false, + readOnly: false, + timezone: "Europe/Madrid", + locale: "es_ES", +}; +LocaleSpanish.storyName = "Locale: Spanish (es_ES)"; + +// Catalan locale +export const LocaleCatalan = LocaleTemplate.bind({}); +LocaleCatalan.args = { + id: "locale-catalan", + value: "2024-03-10 14:30:00", + showTime: true, + required: false, + readOnly: false, + timezone: "Europe/Madrid", + locale: "ca_ES", +}; +LocaleCatalan.storyName = "Locale: Catalan (ca_ES)"; + +// English locale (for comparison) +export const LocaleEnglish = LocaleTemplate.bind({}); +LocaleEnglish.args = { + id: "locale-english", + value: "2024-03-10 14:30:00", + showTime: true, + required: false, + readOnly: false, + timezone: "Europe/Madrid", + locale: "en_US", +}; +LocaleEnglish.storyName = "Locale: English (en_US)"; diff --git a/src/components/widgets/Date/DateInput/DateInput.tsx b/src/components/widgets/Date/DateInput/DateInput.tsx index 88d4dfff..80855068 100644 --- a/src/components/widgets/Date/DateInput/DateInput.tsx +++ b/src/components/widgets/Date/DateInput/DateInput.tsx @@ -10,6 +10,15 @@ import { import { useDatePickerHandlers } from "./hooks/useDatePickerHandlers"; import { DateInputProps } from "./DateInput.types"; import { useRequiredStyle } from "@/hooks/useRequiredStyle"; +import { createGlobalStyle } from "styled-components"; + +// Hide the picker footer (OK button) for legacy DateInput component +// Uses createGlobalStyle because the dropdown is rendered in a portal outside the component tree +const DateInputPickerStyles = createGlobalStyle` + .date-input-picker-dropdown .ant-picker-footer { + display: none !important; + } +`; const DateInput: React.FC = memo((props: DateInputProps) => { const { @@ -91,29 +100,33 @@ const DateInput: React.FC = memo((props: DateInputProps) => { ); return ( - - handleBlur(e as any)} - onKeyDown={(e) => handleKeyDown(e as any)} - showNow={false} - showToday={false} - locale={datePickerLocale} - status={parseError ? "error" : undefined} - /> - + <> + + + handleBlur(e as any)} + onKeyDown={(e) => handleKeyDown(e as any)} + showNow={false} + showToday={false} + locale={datePickerLocale} + status={parseError ? "error" : undefined} + popupClassName="date-input-picker-dropdown" + /> + + ); }); diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.comparison.stories.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.comparison.stories.tsx new file mode 100644 index 00000000..660f1925 --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.comparison.stories.tsx @@ -0,0 +1,339 @@ +import React, { useEffect, useState } from "react"; +import { Meta, StoryFn } from "@storybook/react"; +import { DateMaskedInput } from "./DateMaskedInput"; +import { DateInput } from "../DateInput/DateInput"; +import { Form, Typography, Divider } from "antd"; +import dayjs from "@/helpers/dayjs"; + +const { Text } = Typography; + +export default { + title: "Components/Widgets/Date/DateMaskedInput/Comparison", + parameters: { + layout: "centered", + }, +} as Meta; + +// Side-by-side comparison template +const ComparisonTemplate: StoryFn<{ + storyName: string; + value: string | undefined; + timezone: string; + showTime: boolean; + required?: boolean; + readOnly?: boolean; +}> = (args) => { + const { + storyName, + value, + timezone, + showTime, + required = false, + readOnly = false, + } = args; + + // DateInput state + const [dateInputValue, setDateInputValue] = useState( + value, + ); + + // DateMaskedInput state + const [maskedInputValue, setMaskedInputValue] = useState( + value, + ); + + // Sync initial values when args change + useEffect(() => { + setDateInputValue(value); + setMaskedInputValue(value); + }, [value]); + + const maskedType = showTime ? "datetime" : "date"; + + return ( +
+ + Story: {storyName} + + +
+ {/* DateInput (Old) */} +
+ DateInput (Old) +
+ + setDateInputValue(v || undefined)} + showTime={showTime} + required={required} + readOnly={readOnly} + timezone={timezone} + /> + +
+
+ Debug: +
+              {`value: ${dateInputValue || "(empty)"}
+timezone: ${timezone}
+showTime: ${showTime}`}
+            
+
+
+ + {/* DateMaskedInput (New) */} +
+ DateMaskedInput (New) +
+ + setMaskedInputValue(v || undefined)} + required={required} + readOnly={readOnly} + timezone={timezone} + /> + +
+
+ Debug: +
+              {`value: ${maskedInputValue || "(empty)"}
+timezone: ${timezone}
+type: ${maskedType}`}
+            
+
+
+
+ + {/* Comparison result */} +
+ + Values match:{" "} + {dateInputValue === maskedInputValue ? "✅ YES" : "❌ NO"} + + {dateInputValue !== maskedInputValue && ( +
+
+ Old: {dateInputValue || "(empty)"} +
+
+ New: {maskedInputValue || "(empty)"} +
+
+ )} +
+
+ ); +}; + +// ============================================ +// BASIC STORIES +// ============================================ + +export const Basic = ComparisonTemplate.bind({}); +Basic.args = { + storyName: "Basic", + value: "2024-03-10 14:30:00", + timezone: "Europe/Madrid", + showTime: true, +}; + +export const Required = ComparisonTemplate.bind({}); +Required.args = { + storyName: "Required", + value: "2024-03-10 14:30:00", + timezone: "Europe/Madrid", + showTime: true, + required: true, +}; + +export const ReadOnly = ComparisonTemplate.bind({}); +ReadOnly.args = { + storyName: "ReadOnly", + value: dayjs().format("YYYY-MM-DD HH:mm:ss"), + timezone: "Europe/Madrid", + showTime: true, + readOnly: true, +}; + +export const InvalidDate = ComparisonTemplate.bind({}); +InvalidDate.args = { + storyName: "InvalidDate", + value: "invalid-date", + timezone: "Europe/Madrid", + showTime: true, +}; + +export const DateOnly = ComparisonTemplate.bind({}); +DateOnly.args = { + storyName: "DateOnly", + value: "2024-03-10", + timezone: "Europe/Madrid", + showTime: false, +}; + +// ============================================ +// TIMEZONE STORIES +// ============================================ + +export const TimezoneInOouiMadrid = ComparisonTemplate.bind({}); +TimezoneInOouiMadrid.args = { + storyName: "TimezoneInOouiMadrid", + value: "2025-05-26 12:00:00", + timezone: "Europe/Madrid", + showTime: true, +}; + +export const TimezoneInOouiTokyo = ComparisonTemplate.bind({}); +TimezoneInOouiTokyo.args = { + storyName: "TimezoneInOouiTokyo", + value: "2025-05-26 21:00:00", + timezone: "Asia/Tokyo", + showTime: true, +}; + +export const TimezoneInOouiUTC = ComparisonTemplate.bind({}); +TimezoneInOouiUTC.args = { + storyName: "TimezoneInOouiUTC", + value: "2025-05-26 12:00:00", + timezone: "UTC", + showTime: true, +}; + +// ============================================ +// DST EDGE CASES +// ============================================ + +export const DSTStartMadrid = ComparisonTemplate.bind({}); +DSTStartMadrid.args = { + storyName: "DSTStartMadrid", + value: "2025-03-30 01:59:59", + timezone: "Europe/Madrid", + showTime: true, +}; + +export const DSTEndMadrid = ComparisonTemplate.bind({}); +DSTEndMadrid.args = { + storyName: "DSTEndMadrid", + value: "2025-10-26 02:59:59", + timezone: "Europe/Madrid", + showTime: true, +}; + +export const UTCReference = ComparisonTemplate.bind({}); +UTCReference.args = { + storyName: "UTCReference", + value: "2025-03-30 00:59:59", + timezone: "UTC", + showTime: true, +}; + +export const DSTMadridSpringForward = ComparisonTemplate.bind({}); +DSTMadridSpringForward.args = { + storyName: "DSTMadridSpringForward", + value: "2025-03-30 02:00:00", + timezone: "Europe/Madrid", + showTime: true, +}; + +export const DSTMadridFallBackFirst = ComparisonTemplate.bind({}); +DSTMadridFallBackFirst.args = { + storyName: "DSTMadridFallBackFirst", + value: "2025-10-26 02:00:00", + timezone: "Europe/Madrid", + showTime: true, +}; + +export const DSTMadridFallBackSecond = ComparisonTemplate.bind({}); +DSTMadridFallBackSecond.args = { + storyName: "DSTMadridFallBackSecond", + value: "2025-10-26 02:00:00", + timezone: "Europe/Madrid", + showTime: true, +}; + +export const DSTUtcSpringForward = ComparisonTemplate.bind({}); +DSTUtcSpringForward.args = { + storyName: "DSTUtcSpringForward", + value: "2025-03-30 01:00:00", + timezone: "UTC", + showTime: true, +}; + +export const DSTUtcFallBack = ComparisonTemplate.bind({}); +DSTUtcFallBack.args = { + storyName: "DSTUtcFallBack", + value: "2025-10-26 01:00:00", + timezone: "UTC", + showTime: true, +}; + +export const SpecificUTCTime = ComparisonTemplate.bind({}); +SpecificUTCTime.args = { + storyName: "SpecificUTCTime", + value: "2023-03-26 02:00:00", + timezone: "UTC", + showTime: true, +}; + +export const UTCToMadridDST = ComparisonTemplate.bind({}); +UTCToMadridDST.args = { + storyName: "UTCToMadridDST", + value: "2023-03-26 02:00:00", + timezone: "Europe/Madrid", + showTime: true, +}; + +export const UTCMidnightTransition = ComparisonTemplate.bind({}); +UTCMidnightTransition.args = { + storyName: "UTCMidnightTransition", + value: "2023-03-26 00:00:00", + timezone: "UTC", + showTime: true, +}; + +export const UTCToTokyoNextDay = ComparisonTemplate.bind({}); +UTCToTokyoNextDay.args = { + storyName: "UTCToTokyoNextDay", + value: "2023-03-26 15:00:00", + timezone: "Asia/Tokyo", + showTime: true, +}; + +export const UTCLastSecond = ComparisonTemplate.bind({}); +UTCLastSecond.args = { + storyName: "UTCLastSecond", + value: "2023-03-26 23:59:59", + timezone: "UTC", + showTime: true, +}; diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.stories.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.stories.tsx new file mode 100644 index 00000000..fbed1336 --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.stories.tsx @@ -0,0 +1,625 @@ +import React, { useState } from "react"; +import { Meta, StoryFn } from "@storybook/react"; +import { DateMaskedInput } from "./DateMaskedInput"; +import { Form, Typography, Divider, Space } from "antd"; +import { DateMaskedInputProps } from "./DateMaskedInput.types"; +import { LocaleContextProvider, Locale } from "@/context/LocaleContext"; + +const { Text, Title, Paragraph } = Typography; + +export default { + title: "Components/Widgets/Date/DateMaskedInput", + component: DateMaskedInput, + parameters: { + layout: "centered", + actions: { disable: true }, + }, + argTypes: { + type: { + control: "select", + options: ["date", "datetime", "time"], + description: "Type of input", + }, + timezone: { + control: "select", + options: [ + "UTC", + "Europe/Madrid", + "Europe/London", + "America/New_York", + "Asia/Tokyo", + "Australia/Sydney", + ], + description: "Timezone (for date/datetime)", + }, + required: { + control: "boolean", + description: "Shows yellow background when required", + }, + readOnly: { + control: "boolean", + description: "Disables the input", + }, + useZeros: { + control: "boolean", + description: "For time type: use 00:00:00 instead of current time", + }, + }, +} as Meta; + +// Template with debug info - matching DateInput format exactly +const Template: StoryFn = (args) => { + const [value, setValue] = useState(args.value); + // Exclude auto-generated onChange action from args to avoid conflicts + const { onChange: _onChange, ...restArgs } = args as DateMaskedInputProps & { + onChange?: unknown; + }; + + return ( +
+
+ + setValue(v || undefined)} + /> + +
+ Debug Information: +
String value: {value}
+
timezone: {args.timezone}
+
type: {args.type}
+
required: {args.required?.toString()}
+
readOnly: {args.readOnly?.toString()}
+
+
+
+ ); +}; + +// ============================================ +// BASIC STORIES (matching DateInput) +// ============================================ + +// Date picker with time +export const Basic = Template.bind({}); +Basic.args = { + type: "datetime", + id: "basic-date", + value: "2024-03-10 14:30:00", + required: false, + readOnly: false, + timezone: "Europe/Madrid", +}; + +// Required field +export const Required = Template.bind({}); +Required.args = { + type: "datetime", + id: "required-date", + value: "2024-03-10 14:30:00", + required: true, + readOnly: false, + timezone: "Europe/Madrid", +}; + +// Read-only field +export const ReadOnly = Template.bind({}); +ReadOnly.args = { + type: "datetime", + id: "readonly-date", + value: "2024-03-10 14:30:00", + required: false, + readOnly: true, + timezone: "Europe/Madrid", +}; + +// Invalid date handling +export const InvalidDate = Template.bind({}); +InvalidDate.args = { + type: "datetime", + id: "invalid-date", + value: "invalid-date", + required: false, + readOnly: false, + timezone: "Europe/Madrid", +}; + +// Date only (no time) +export const DateOnly = Template.bind({}); +DateOnly.args = { + type: "date", + id: "date-only", + value: "2024-03-10", + required: false, + readOnly: false, + timezone: "Europe/Madrid", +}; + +// ============================================ +// TIMEZONE STORIES +// ============================================ + +export const TimezoneInOouiMadrid = Template.bind({}); +TimezoneInOouiMadrid.args = { + type: "datetime", + id: "madrid-tz", + value: "2025-05-26 12:00:00", + timezone: "Europe/Madrid", +}; + +export const TimezoneInOouiTokyo = Template.bind({}); +TimezoneInOouiTokyo.args = { + type: "datetime", + id: "tokyo-tz", + value: "2025-05-26 21:00:00", + timezone: "Asia/Tokyo", +}; + +export const TimezoneInOouiUTC = Template.bind({}); +TimezoneInOouiUTC.args = { + type: "datetime", + id: "utc-tz", + value: "2025-05-26 12:00:00", + timezone: "UTC", +}; + +// ============================================ +// DST EDGE CASES +// ============================================ + +export const DSTStartMadrid = Template.bind({}); +DSTStartMadrid.args = { + type: "datetime", + id: "dst-start-madrid", + value: "2025-03-30 01:59:59", + timezone: "Europe/Madrid", +}; + +export const DSTEndMadrid = Template.bind({}); +DSTEndMadrid.args = { + type: "datetime", + id: "dst-end-madrid", + value: "2025-10-26 02:59:59", + timezone: "Europe/Madrid", +}; + +export const UTCReference = Template.bind({}); +UTCReference.args = { + type: "datetime", + id: "utc-reference", + value: "2025-03-30 00:59:59", + timezone: "UTC", +}; + +export const DSTMadridSpringForward = Template.bind({}); +DSTMadridSpringForward.args = { + type: "datetime", + id: "dst-madrid-spring", + value: "2025-03-30 02:00:00", + timezone: "Europe/Madrid", +}; + +export const DSTMadridFallBackFirst = Template.bind({}); +DSTMadridFallBackFirst.args = { + type: "datetime", + id: "dst-madrid-fall-first", + value: "2025-10-26 02:00:00", + timezone: "Europe/Madrid", +}; + +export const DSTMadridFallBackSecond = Template.bind({}); +DSTMadridFallBackSecond.args = { + type: "datetime", + id: "dst-madrid-fall-second", + value: "2025-10-26 02:00:00", + timezone: "Europe/Madrid", +}; + +export const DSTUtcSpringForward = Template.bind({}); +DSTUtcSpringForward.args = { + type: "datetime", + id: "dst-utc-spring", + value: "2025-03-30 01:00:00", + timezone: "UTC", +}; + +export const DSTUtcFallBack = Template.bind({}); +DSTUtcFallBack.args = { + type: "datetime", + id: "dst-utc-fall", + value: "2025-10-26 01:00:00", + timezone: "UTC", +}; + +export const SpecificUTCTime = Template.bind({}); +SpecificUTCTime.args = { + type: "datetime", + id: "specific-utc", + value: "2023-03-26 02:00:00", + timezone: "UTC", +}; + +export const UTCToMadridDST = Template.bind({}); +UTCToMadridDST.args = { + type: "datetime", + id: "utc-to-madrid-dst", + value: "2023-03-26 02:00:00", + timezone: "Europe/Madrid", +}; + +export const UTCMidnightTransition = Template.bind({}); +UTCMidnightTransition.args = { + type: "datetime", + id: "utc-midnight", + value: "2023-03-26 00:00:00", + timezone: "UTC", +}; + +export const UTCToTokyoNextDay = Template.bind({}); +UTCToTokyoNextDay.args = { + type: "datetime", + id: "utc-to-tokyo", + value: "2023-03-26 15:00:00", + timezone: "Asia/Tokyo", +}; + +export const UTCLastSecond = Template.bind({}); +UTCLastSecond.args = { + type: "datetime", + id: "utc-last-second", + value: "2023-03-26 23:59:59", + timezone: "UTC", +}; + +// ============================================ +// ADDITIONAL: AUTOCOMPLETE DEMO +// ============================================ + +const AutocompleteDemo: StoryFn = () => { + const [values, setValues] = useState>({}); + + const updateValue = (key: string, value: string | null | undefined) => { + setValues((prev) => ({ ...prev, [key]: value || undefined })); + }; + + return ( +
+ Autocomplete Scenarios + + Type partial values and press Enter to see autocomplete in action. + + + Date + + + Type 15 → autocompletes month/year + + } + style={{ marginBottom: 8 }} + > + updateValue("date1", v)} + /> + {values["date1"] && → {values["date1"]}} + + + Type 15/06 → autocompletes year + + } + style={{ marginBottom: 8 }} + > + updateValue("date2", v)} + /> + {values["date2"] && → {values["date2"]}} + + + + DateTime + + + Type 15/06/2024 14 → autocompletes min:sec + + } + style={{ marginBottom: 8 }} + > + updateValue("datetime1", v)} + /> + {values["datetime1"] && ( + → {values["datetime1"]} + )} + + + + Time + + + Type 14 → autocompletes min:sec + + } + style={{ marginBottom: 8 }} + > + updateValue("time1", v)} + /> + {values["time1"] && → {values["time1"]}} + + + Type 14:30 → autocompletes seconds + + } + style={{ marginBottom: 8 }} + > + updateValue("time2", v)} + /> + {values["time2"] && → {values["time2"]}} + + +
+ ); +}; + +export const Autocomplete = AutocompleteDemo.bind({}); +Autocomplete.parameters = { + docs: { + description: { + story: + "Interactive demo showing autocomplete for all input types. Users can enter partial values and the component fills in the rest from current date/time.", + }, + }, +}; + +// ============================================ +// ADDITIONAL: KEYBOARD BEHAVIOR TEST +// ============================================ + +const KeyboardDemo: StoryFn = () => { + const [values, setValues] = useState>({ + clear: "2024-06-15", + }); + const [logs, setLogs] = useState([]); + + const addLog = (msg: string) => { + setLogs((prev) => [ + ...prev.slice(-9), + `${new Date().toLocaleTimeString()}: ${msg}`, + ]); + }; + + const updateValue = (key: string, value: string | null | undefined) => { + setValues((prev) => ({ ...prev, [key]: value || undefined })); + addLog(`${key}: ${value ?? "null"}`); + }; + + return ( +
+ Keyboard & Mouse Behavior + +
+ + 1. Enter Key + - Type "23" then Enter + + } + > + updateValue("enter", v)} + /> + {values["enter"] && → {values["enter"]}} + + + + 2. Blur + + {" "} + - Type "10" then click outside + + + } + > + updateValue("blur", v)} + /> + {values["blur"] && → {values["blur"]}} + + + + 3. Double-click + + {" "} + - Type "25/12" then double-click + + + } + > + updateValue("dblclick", v)} + /> + {values["dblclick"] && ( + → {values["dblclick"]} + )} + + + + 4. Clear value + + {" "} + - Select all, backspace, blur (or hover + click X) + + + } + > + updateValue("clear", v)} + /> + + {" "} + → {values["clear"] || "(empty)"} + + +
+ +
+ Event Log: + {logs.length === 0 ? ( +
No events yet...
+ ) : ( + logs.map((log, i) =>
{log}
) + )} +
+
+ ); +}; + +export const KeyboardBehavior = KeyboardDemo.bind({}); +KeyboardBehavior.parameters = { + docs: { + description: { + story: ` +Test keyboard and mouse behaviors: +- **Enter**: Autocomplete and commit +- **Escape**: Commit and move to next element +- **Blur**: Autocomplete and commit +- **Double-click**: Autocomplete and commit +- **Clear**: Delete content + blur, or hover + click X icon + `, + }, + }, +}; + +// ============================================ +// LOCALE STORIES +// ============================================ + +type LocaleStoryArgs = DateMaskedInputProps & { + locale: Locale; +}; + +const LocaleTemplate: StoryFn = (args) => { + const [value, setValue] = useState(args.value); + const { onChange: _onChange, ...restArgs } = args as LocaleStoryArgs & { + onChange?: unknown; + }; + + return ( + +
+
+ + setValue(v || undefined)} + /> + +
+ Debug Information: +
String value: {value}
+
locale: {args.locale}
+
timezone: {args.timezone}
+
type: {args.type}
+
+
+
+
+ ); +}; + +// Spanish locale +export const LocaleSpanish = LocaleTemplate.bind({}); +LocaleSpanish.args = { + type: "datetime", + id: "locale-spanish", + value: "2024-03-10 14:30:00", + required: false, + readOnly: false, + timezone: "Europe/Madrid", + locale: "es_ES", +}; +LocaleSpanish.storyName = "Locale: Spanish (es_ES)"; + +// Catalan locale +export const LocaleCatalan = LocaleTemplate.bind({}); +LocaleCatalan.args = { + type: "datetime", + id: "locale-catalan", + value: "2024-03-10 14:30:00", + required: false, + readOnly: false, + timezone: "Europe/Madrid", + locale: "ca_ES", +}; +LocaleCatalan.storyName = "Locale: Catalan (ca_ES)"; + +// English locale (for comparison) +export const LocaleEnglish = LocaleTemplate.bind({}); +LocaleEnglish.args = { + type: "datetime", + id: "locale-english", + value: "2024-03-10 14:30:00", + required: false, + readOnly: false, + timezone: "Europe/Madrid", + locale: "en_US", +}; +LocaleEnglish.storyName = "Locale: English (en_US)"; diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.styles.ts b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.styles.ts new file mode 100644 index 00000000..d8a0ed30 --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.styles.ts @@ -0,0 +1,159 @@ +import { IMaskInput } from "react-imask"; +import styled, { createGlobalStyle } from "styled-components"; + +export const DateMaskedInputPickerStyles = createGlobalStyle` + .date-masked-input-picker-dropdown .ant-picker-footer { + display: block !important; + } +`; + +export const InputWrapper = styled.div.attrs<{ + $required?: React.CSSProperties; + $disabled?: boolean; + $hasError?: boolean; + $colorBgContainer?: string; +}>((props) => ({ + className: `ant-picker ant-picker-outlined${ + props.$disabled ? " ant-picker-disabled" : "" + }${props.$hasError ? " ant-picker-status-error" : ""}`, +}))<{ + $required?: React.CSSProperties; + $disabled?: boolean; + $hasError?: boolean; + $colorBgContainer?: string; +}>` + display: flex; + align-items: center; + width: 100%; + position: relative; + background-color: ${(props) => + props.$required?.backgroundColor || props.$colorBgContainer}; + border-radius: 6px; +`; + +export const StyledInput = styled(IMaskInput)<{ + $hasError?: boolean; + $required?: React.CSSProperties; + $isEmpty?: boolean; + $placeholderColor?: string; + $textColor?: string; + $colorError?: string; + $colorBorder?: string; + $colorPrimary?: string; + $colorErrorBg?: string; + $colorPrimaryBg?: string; + $colorTextDisabled?: string; + $colorBgContainerDisabled?: string; +}>` + flex: 1; + width: 100%; + height: 32px; + padding: 4px 11px; + font-size: 14px; + line-height: 1.5715; + color: ${(props) => + props.$isEmpty ? props.$placeholderColor : props.$textColor}; + background-color: transparent; + border: 1px solid + ${(props) => (props.$hasError ? props.$colorError : props.$colorBorder)}; + border-radius: 6px; + transition: all 0.2s; + font-family: inherit; + + &:focus { + border-color: ${(props) => + props.$hasError ? props.$colorError : props.$colorPrimary}; + box-shadow: 0 0 0 2px + ${(props) => + props.$hasError ? props.$colorErrorBg : props.$colorPrimaryBg}; + outline: none; + } + + &:hover { + border-color: ${(props) => + props.$hasError ? props.$colorError : props.$colorPrimary}; + } + + &:disabled { + color: ${(props) => props.$colorTextDisabled}; + background-color: ${(props) => props.$colorBgContainerDisabled}; + cursor: not-allowed; + } +`; + +const SuffixIcon = styled.span<{ + $allowClear?: boolean; + $colorTextQuaternary?: string; + $colorTextSecondary?: string; +}>` + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + color: ${(props) => props.$colorTextQuaternary}; + cursor: ${(props) => (props.$allowClear ? "pointer" : "default")}; + transition: + color 0.2s, + opacity 0.2s; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${(props) => + props.$allowClear + ? props.$colorTextSecondary + : props.$colorTextQuaternary}; + } +`; + +export const ClearIcon = styled(SuffixIcon)` + opacity: 0; +`; + +export const CalendarIcon = styled(SuffixIcon)` + opacity: 1; +`; + +export const InputContainer = styled.div.attrs({ + className: "ant-picker-input", +})` + position: relative; + flex: 1; + display: flex; + align-items: center; + + &:hover ${ClearIcon} { + opacity: 1; + } + + &:hover ${CalendarIcon} { + opacity: 0; + pointer-events: none; + } +`; + +export const HiddenPickerWrapper = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: hidden; + + > .ant-picker { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + visibility: hidden; + pointer-events: none; + } + + .ant-picker-dropdown { + visibility: visible; + } +`; diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx new file mode 100644 index 00000000..a009bb23 --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx @@ -0,0 +1,702 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + DatePicker as AntDatePicker, + TimePicker as AntTimePicker, + Tooltip, + theme, +} from "antd"; +import { + CalendarOutlined, + ClockCircleOutlined, + CloseCircleFilled, +} from "@ant-design/icons"; +import dayjs, { Dayjs } from "dayjs"; +import { + DateMaskedInputProps, + DateMaskedInputType, +} from "./DateMaskedInput.types"; +import { + MaskedDateConfig, + MaskedDateTimeConfig, + MaskedTimeConfig, + autocompleteDate, + autocompleteDateTime, + autocompleteTime, + parseInternalToDisplay, + parseDisplayToInternal, + isCompleteValue, + hasAnyDigits, +} from "./MaskedDate.helpers"; +import { useRequiredStyle } from "@/hooks/useRequiredStyle"; +import { useDatePickerLocale } from "../DateInput/hooks/useDatePickerLocale"; +import { + DateMaskedInputPickerStyles, + InputWrapper, + StyledInput, + ClearIcon, + CalendarIcon, + InputContainer, + HiddenPickerWrapper, +} from "./DateMaskedInput.styles"; + +function getConfig(type: DateMaskedInputType) { + switch (type) { + case "date": + return MaskedDateConfig; + case "datetime": + return MaskedDateTimeConfig; + case "time": + return MaskedTimeConfig; + } +} + +const DateMaskedInputComponent = memo(function DateMaskedInput( + props: DateMaskedInputProps, +): React.ReactElement { + const { + type, + value, + onChange, + id, + readOnly = false, + required = false, + timezone = "Europe/Madrid", + placeholder, + useZeros = false, + } = props; + + const config = getConfig(type); + const { token } = theme.useToken(); + const inputRef = useRef(null); + const wrapperRef = useRef(null); + const skipNextFocusRef = useRef(false); + const clickedInsideRef = useRef(false); + const [pickerOpen, setPickerOpen] = useState(false); + const [parseError, setParseError] = useState(null); + const [inputValue, setInputValue] = useState(undefined); + + const datePickerLocale = useDatePickerLocale(); + const requiredStyle = useRequiredStyle(required, readOnly); + const effectivePlaceholder = placeholder || config.placeholder; + + const isValuePropValid = useMemo(() => { + if (!value) return true; + + if (type === "time") { + try { + const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); + return parsed.isValid(); + } catch { + return false; + } + } + + try { + const parsed = timezone + ? dayjs.tz(value, config.internalFormat, timezone) + : dayjs(value, config.internalFormat); + return parsed.isValid(); + } catch { + return false; + } + }, [value, timezone, type, config]); + + useEffect(() => { + if (value && !isValuePropValid) { + setParseError("Invalid date format"); + } else if (isValuePropValid && !inputValue) { + setParseError(null); + } + }, [value, isValuePropValid, inputValue]); + + const displayValue = useMemo(() => { + if (!value) return ""; + + if (type === "time") { + try { + const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); + return parsed.isValid() ? parsed.format(config.displayFormat) : ""; + } catch { + return ""; + } + } + + return parseInternalToDisplay( + value, + config.internalFormat, + config.displayFormat, + timezone, + ); + }, [value, timezone, type, config]); + + const currentInputValue = inputValue ?? displayValue; + + const pickerValue = useMemo(() => { + if (inputValue !== undefined && inputValue !== "") { + try { + if (type === "time") { + const parsed = dayjs( + `2000-01-01 ${inputValue}`, + "YYYY-MM-DD HH:mm:ss", + ); + return parsed.isValid() ? parsed : undefined; + } + const parsed = dayjs(inputValue, config.displayFormat); + if (parsed.isValid()) { + return timezone + ? dayjs.tz( + parsed.format(config.internalFormat), + config.internalFormat, + timezone, + ) + : parsed; + } + return undefined; + } catch { + return undefined; + } + } + + if (!value) return undefined; + try { + if (type === "time") { + const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); + return parsed.isValid() ? parsed : undefined; + } + const parsed = timezone + ? dayjs.tz(value, config.internalFormat, timezone) + : dayjs(value, config.internalFormat); + return parsed.isValid() ? parsed : undefined; + } catch { + return undefined; + } + }, [value, inputValue, timezone, type, config]); + + const clearError = useCallback(() => { + setInputValue(undefined); + setParseError(null); + }, []); + + const handleAccept = useCallback((maskedValue: string) => { + setInputValue(maskedValue); + setParseError(null); + }, []); + + const autocompleteFn = useCallback( + (maskedValue: string) => { + switch (type) { + case "date": + return autocompleteDate(maskedValue); + case "datetime": + return autocompleteDateTime(maskedValue); + case "time": + return autocompleteTime(maskedValue, dayjs(), useZeros); + } + }, + [type, useZeros], + ); + + const commitValue = useCallback( + (maskedValue: string) => { + const isEmpty = + !maskedValue || + !hasAnyDigits(maskedValue) || + maskedValue === effectivePlaceholder; + + if (isEmpty) { + onChange?.(null); + clearError(); + return; + } + + if (isCompleteValue(maskedValue, effectivePlaceholder)) { + if (type === "time") { + onChange?.(maskedValue); + clearError(); + } else { + const internalValue = parseDisplayToInternal( + maskedValue, + config.displayFormat, + config.internalFormat, + timezone, + ); + if (internalValue) { + onChange?.(internalValue); + clearError(); + } else { + setParseError(`Invalid ${type} format`); + } + } + return; + } + + const autocompleted = autocompleteFn(maskedValue); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + clearError(); + } else { + setParseError(`Invalid ${type} format`); + } + }, + [ + onChange, + effectivePlaceholder, + type, + config, + autocompleteFn, + timezone, + clearError, + ], + ); + + const autocompleteEmpty = useCallback(() => { + const autocompleted = autocompleteFn(""); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + clearError(); + } + }, [autocompleteFn, onChange, clearError]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + const maskedValue = input.value; + + if (!hasAnyDigits(maskedValue)) { + autocompleteEmpty(); + return; + } + + commitValue(maskedValue); + } else if (e.key === "Escape") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + + setPickerOpen(false); + commitValue(input.value); + + setTimeout(() => { + const focusableElements = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const elements = Array.from( + document.querySelectorAll(focusableElements), + ) as HTMLElement[]; + const index = elements.indexOf(input); + if (index > -1 && index < elements.length - 1) { + elements[index + 1].focus(); + } + }, 50); + } else if (e.key === "Tab") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + const shiftKey = e.shiftKey; + setPickerOpen(false); + commitValue(input.value); + + input.blur(); + + requestAnimationFrame(() => { + const focusableSelector = + 'input:not([disabled]):not([tabindex="-1"]), ' + + 'button:not([disabled]):not([tabindex="-1"]), ' + + 'select:not([disabled]):not([tabindex="-1"]), ' + + 'textarea:not([disabled]):not([tabindex="-1"]), ' + + '[tabindex]:not([tabindex="-1"]):not([disabled])'; + + const allFocusable = Array.from( + document.querySelectorAll(focusableSelector), + ).filter((el) => { + const htmlEl = el as HTMLElement; + return ( + htmlEl.offsetParent !== null && + !el.closest(".ant-picker-dropdown") && + getComputedStyle(htmlEl).visibility !== "hidden" + ); + }) as HTMLElement[]; + + const currentIndex = allFocusable.findIndex( + (el) => el === inputRef.current, + ); + + if (currentIndex !== -1) { + const nextIndex = shiftKey + ? Math.max(0, currentIndex - 1) + : Math.min(allFocusable.length - 1, currentIndex + 1); + + if (nextIndex !== currentIndex) { + allFocusable[nextIndex].focus(); + } + } + }); + } + }, + [commitValue, autocompleteEmpty], + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const input = e.target as HTMLInputElement; + const maskedValue = input.value; + + if (!hasAnyDigits(maskedValue)) { + autocompleteEmpty(); + return; + } + + commitValue(maskedValue); + }, + [commitValue, autocompleteEmpty], + ); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null; + if (relatedTarget?.closest(".ant-picker-dropdown")) { + return; + } + setPickerOpen(false); + commitValue(e.target.value); + }, + [commitValue], + ); + + const handleFocus = useCallback(() => { + if (skipNextFocusRef.current) { + skipNextFocusRef.current = false; + return; + } + if (!readOnly) { + setPickerOpen(true); + } + }, [readOnly]); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setPickerOpen(false); + clearError(); + onChange?.(null); + }, + [onChange, clearError], + ); + + const handlePickerChange = useCallback( + (date: Dayjs | null) => { + if (!date) { + onChange?.(null); + clearError(); + return; + } + + if (type === "date") { + onChange?.(date.format(config.internalFormat)); + clearError(); + setPickerOpen(false); + skipNextFocusRef.current = true; + setTimeout(() => inputRef.current?.focus(), 50); + } + }, + [type, config, onChange, clearError], + ); + + const updateInputFromDate = useCallback( + (date: Dayjs) => { + setInputValue(date.format(config.displayFormat)); + setParseError(null); + }, + [config], + ); + + const handleDateCellClick = useCallback( + (date: Dayjs) => { + const currentTime = pickerValue || dayjs(); + const newDateTime = date + .hour(currentTime.hour()) + .minute(currentTime.minute()) + .second(currentTime.second()); + updateInputFromDate(newDateTime); + }, + [pickerValue, updateInputFromDate], + ); + + const handleMonthCellClick = useCallback( + (month: number) => { + const currentDate = pickerValue || dayjs(); + updateInputFromDate(currentDate.month(month)); + }, + [pickerValue, updateInputFromDate], + ); + + const handleYearCellClick = useCallback( + (year: number) => { + const currentDate = pickerValue || dayjs(); + updateInputFromDate(currentDate.year(year)); + }, + [pickerValue, updateInputFromDate], + ); + + const cellRender: React.ComponentProps["cellRender"] = + useCallback( + ( + current: string | number | Dayjs, + info: { type: string; originNode: React.ReactNode }, + ) => { + if ( + info.type === "date" && + dayjs.isDayjs(current) && + type === "datetime" + ) { + return ( +
{ + e.stopPropagation(); + handleDateCellClick(current); + }} + > + {current.date()} +
+ ); + } + if (info.type === "month" && dayjs.isDayjs(current)) { + return ( +
handleMonthCellClick(current.month())}> + {info.originNode} +
+ ); + } + if (info.type === "year" && dayjs.isDayjs(current)) { + return ( +
handleYearCellClick(current.year())}> + {info.originNode} +
+ ); + } + return info.originNode; + }, + [handleDateCellClick, handleMonthCellClick, handleYearCellClick, type], + ); + + const panelRender = useCallback( + (originPanel: React.ReactNode) => { + const handlePanelClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + const timeCell = target.closest( + ".ant-picker-time-panel-cell", + ) as HTMLElement; + if (!timeCell) return; + + const column = timeCell.closest(".ant-picker-time-panel-column"); + if (!column) return; + + const panel = e.currentTarget as HTMLElement; + const columns = Array.from( + panel.querySelectorAll(".ant-picker-time-panel-column"), + ); + const columnIndex = columns.indexOf(column); + + const cellValue = parseInt( + timeCell.querySelector(".ant-picker-time-panel-cell-inner") + ?.textContent || "0", + 10, + ); + + const currentDate = pickerValue || dayjs(); + let newDateTime: Dayjs; + if (columnIndex === 0) { + newDateTime = currentDate.hour(cellValue); + } else if (columnIndex === 1) { + newDateTime = currentDate.minute(cellValue); + } else { + newDateTime = currentDate.second(cellValue); + } + + updateInputFromDate(newDateTime); + }; + + return
{originPanel}
; + }, + [pickerValue, updateInputFromDate], + ); + + const handleOk = useCallback( + (date: Dayjs) => { + const format = type === "time" ? "HH:mm:ss" : config.internalFormat; + onChange?.(date.format(format)); + clearError(); + setPickerOpen(false); + skipNextFocusRef.current = true; + setTimeout(() => inputRef.current?.focus(), 50); + }, + [type, config, onChange, clearError], + ); + + const Icon = type === "time" ? ClockCircleOutlined : CalendarOutlined; + + const handleWrapperMouseDown = useCallback(() => { + clickedInsideRef.current = true; + setTimeout(() => { + clickedInsideRef.current = false; + }, 100); + }, []); + + const handleWrapperClick = useCallback(() => { + if (!readOnly) { + setPickerOpen(true); + inputRef.current?.focus(); + } + }, [readOnly]); + + const handleIconClick = useCallback(() => { + if (!readOnly) { + setPickerOpen(true); + inputRef.current?.focus(); + } + }, [readOnly]); + + const handleClearMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleOpenChange = useCallback((open: boolean) => { + if (!open && !clickedInsideRef.current) { + setPickerOpen(false); + } + }, []); + + const getPopupContainer = useCallback(() => document.body, []); + + const pickerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []); + const wrapperStyle = useMemo(() => ({ position: "relative" as const }), []); + const inputPaddingStyle = useMemo(() => ({ paddingRight: 30 }), []); + const iconStyle = useMemo(() => ({ fontSize: 14 }), []); + const clearIconStyle = useMemo(() => ({ fontSize: 12 }), []); + const opacityStyle = useMemo(() => ({ opacity: 1 }), []); + + const renderPicker = () => { + const commonProps = { + open: pickerOpen, + onOpenChange: handleOpenChange, + value: pickerValue, + onChange: handlePickerChange, + locale: datePickerLocale, + getPopupContainer, + showNow: false, + showToday: false, + allowClear: false, + inputReadOnly: true, + style: pickerStyle, + }; + + if (type === "time") { + return ( + + ); + } + + return ( + + ); + }; + + return ( + <> + + +
+ + + + {!readOnly && ( + + + + )} + {!readOnly && value && ( + + + + )} + + + + {renderPicker()} +
+
+ + ); +}); + +DateMaskedInputComponent.displayName = "DateMaskedInput"; + +export { DateMaskedInputComponent as DateMaskedInput }; diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.types.ts b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.types.ts new file mode 100644 index 00000000..bd34f50e --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.types.ts @@ -0,0 +1,22 @@ +export type DateMaskedInputType = "date" | "datetime" | "time"; + +export type DateMaskedInputProps = { + /** Type of input: date, datetime, or time */ + type: DateMaskedInputType; + /** Value in internal format (YYYY-MM-DD, YYYY-MM-DD HH:mm:ss, or HH:mm:ss) */ + value?: string; + /** Callback when value changes */ + onChange?: (value: string | null | undefined) => void; + /** Input element id */ + id?: string; + /** Whether the field is read-only */ + readOnly?: boolean; + /** Whether the field is required (shows yellow background) */ + required?: boolean; + /** Timezone for date/datetime parsing (default: Europe/Madrid) */ + timezone?: string; + /** Custom placeholder (defaults based on type) */ + placeholder?: string; + /** For time type: use zeros instead of current time for autocomplete */ + useZeros?: boolean; +}; diff --git a/src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.test.ts b/src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.test.ts new file mode 100644 index 00000000..a638b197 --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.test.ts @@ -0,0 +1,301 @@ +import dayjs from "dayjs"; +import { + autocompleteDate, + autocompleteDateTime, + autocompleteTime, + parseInternalToDisplay, + parseDisplayToInternal, + isCompleteValue, + hasAnyDigits, + MaskedDateConfig, + MaskedDateTimeConfig, + MaskedTimeConfig, +} from "./MaskedDate.helpers"; + +describe("MaskedDate.helpers", () => { + // Use a fixed date for consistent tests + const fixedNow = dayjs("2024-06-15 10:30:45"); + + describe("autocompleteDate", () => { + it("should return current date when input is empty", () => { + const result = autocompleteDate("", fixedNow); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("15/06/2024"); + expect(result!.internalValue).toBe("2024-06-15"); + }); + + it("should autocomplete day only with current month and year", () => { + const result = autocompleteDate("25", fixedNow); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("25/06/2024"); + expect(result!.internalValue).toBe("2024-06-25"); + }); + + it("should autocomplete day and month with current year", () => { + const result = autocompleteDate("25/03", fixedNow); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("25/03/2024"); + expect(result!.internalValue).toBe("2024-03-25"); + }); + + it("should handle complete date input", () => { + const result = autocompleteDate("25/03/2023", fixedNow); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("25/03/2023"); + expect(result!.internalValue).toBe("2023-03-25"); + }); + + it("should return null for invalid day", () => { + const result = autocompleteDate("ab", fixedNow); + expect(result).toBeNull(); + }); + + it("should return null for empty day with month", () => { + const result = autocompleteDate("/03", fixedNow); + expect(result).toBeNull(); + }); + }); + + describe("autocompleteDateTime", () => { + it("should return current datetime when input is empty", () => { + const result = autocompleteDateTime("", fixedNow); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("15/06/2024 10:30:45"); + expect(result!.internalValue).toBe("2024-06-15 10:30:45"); + }); + + it("should autocomplete day with current time", () => { + const result = autocompleteDateTime("25", fixedNow); + expect(result).not.toBeNull(); + expect(result!.displayValue).toContain("25/06/2024"); + expect(result!.internalValue).toContain("2024-06-25"); + }); + + it("should autocomplete date with partial time", () => { + const result = autocompleteDateTime("25/03/2024 14", fixedNow); + expect(result).not.toBeNull(); + expect(result!.displayValue).toContain("25/03/2024 14:"); + expect(result!.internalValue).toContain("2024-03-25 14:"); + }); + + it("should handle complete datetime input", () => { + const result = autocompleteDateTime("25/03/2024 14:30:00", fixedNow); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("25/03/2024 14:30:00"); + expect(result!.internalValue).toBe("2024-03-25 14:30:00"); + }); + + it("should return null for invalid day", () => { + const result = autocompleteDateTime("ab/03/2024", fixedNow); + expect(result).toBeNull(); + }); + }); + + describe("autocompleteTime", () => { + it("should return current time when input is empty", () => { + const result = autocompleteTime("", fixedNow, false); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("10:30:45"); + expect(result!.internalValue).toBe("10:30:45"); + }); + + it("should autocomplete hours with current minutes/seconds", () => { + const result = autocompleteTime("14", fixedNow, false); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("14:30:45"); + expect(result!.internalValue).toBe("14:30:45"); + }); + + it("should autocomplete hours with zeros when useZeros is true", () => { + const result = autocompleteTime("14", fixedNow, true); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("14:00:00"); + expect(result!.internalValue).toBe("14:00:00"); + }); + + it("should autocomplete hours and minutes", () => { + const result = autocompleteTime("14:25", fixedNow, false); + expect(result).not.toBeNull(); + expect(result!.displayValue).toContain("14:25:"); + }); + + it("should handle complete time input", () => { + const result = autocompleteTime("14:25:30", fixedNow, false); + expect(result).not.toBeNull(); + expect(result!.displayValue).toBe("14:25:30"); + expect(result!.internalValue).toBe("14:25:30"); + }); + + it("should return null for invalid hours", () => { + const result = autocompleteTime("ab", fixedNow, false); + expect(result).toBeNull(); + }); + }); + + describe("parseInternalToDisplay", () => { + it("should return empty string for undefined value", () => { + const result = parseInternalToDisplay( + undefined, + MaskedDateConfig.internalFormat, + MaskedDateConfig.displayFormat, + ); + expect(result).toBe(""); + }); + + it("should return empty string for empty value", () => { + const result = parseInternalToDisplay( + "", + MaskedDateConfig.internalFormat, + MaskedDateConfig.displayFormat, + ); + expect(result).toBe(""); + }); + + it("should convert date from internal to display format", () => { + const result = parseInternalToDisplay( + "2024-03-25", + MaskedDateConfig.internalFormat, + MaskedDateConfig.displayFormat, + ); + expect(result).toBe("25/03/2024"); + }); + + it("should convert datetime from internal to display format", () => { + const result = parseInternalToDisplay( + "2024-03-25 14:30:00", + MaskedDateTimeConfig.internalFormat, + MaskedDateTimeConfig.displayFormat, + ); + expect(result).toBe("25/03/2024 14:30:00"); + }); + + it("should return empty string for invalid date", () => { + const result = parseInternalToDisplay( + "invalid-date", + MaskedDateConfig.internalFormat, + MaskedDateConfig.displayFormat, + ); + expect(result).toBe(""); + }); + }); + + describe("parseDisplayToInternal", () => { + it("should return null for empty value", () => { + const result = parseDisplayToInternal( + "", + MaskedDateConfig.displayFormat, + MaskedDateConfig.internalFormat, + ); + expect(result).toBeNull(); + }); + + it("should return null for value with underscores (incomplete)", () => { + const result = parseDisplayToInternal( + "25/__/2024", + MaskedDateConfig.displayFormat, + MaskedDateConfig.internalFormat, + ); + expect(result).toBeNull(); + }); + + it("should convert date from display to internal format", () => { + const result = parseDisplayToInternal( + "25/03/2024", + MaskedDateConfig.displayFormat, + MaskedDateConfig.internalFormat, + ); + expect(result).toBe("2024-03-25"); + }); + + it("should convert datetime from display to internal format", () => { + const result = parseDisplayToInternal( + "25/03/2024 14:30:00", + MaskedDateTimeConfig.displayFormat, + MaskedDateTimeConfig.internalFormat, + ); + expect(result).toBe("2024-03-25 14:30:00"); + }); + + it("should return null for invalid date", () => { + const result = parseDisplayToInternal( + "invalid-date", + MaskedDateConfig.displayFormat, + MaskedDateConfig.internalFormat, + ); + expect(result).toBeNull(); + }); + }); + + describe("isCompleteValue", () => { + it("should return true for complete date value", () => { + const result = isCompleteValue( + "25/03/2024", + MaskedDateConfig.placeholder, + ); + expect(result).toBe(true); + }); + + it("should return false for incomplete date value", () => { + const result = isCompleteValue( + "25/03/____", + MaskedDateConfig.placeholder, + ); + expect(result).toBe(false); + }); + + it("should return false for shorter value", () => { + const result = isCompleteValue("25/03", MaskedDateConfig.placeholder); + expect(result).toBe(false); + }); + + it("should return true for complete datetime value", () => { + const result = isCompleteValue( + "25/03/2024 14:30:00", + MaskedDateTimeConfig.placeholder, + ); + expect(result).toBe(true); + }); + + it("should return true for complete time value", () => { + const result = isCompleteValue("14:30:00", MaskedTimeConfig.placeholder); + expect(result).toBe(true); + }); + }); + + describe("hasAnyDigits", () => { + it("should return true for string with digits", () => { + expect(hasAnyDigits("25/03/2024")).toBe(true); + expect(hasAnyDigits("abc123")).toBe(true); + expect(hasAnyDigits("1")).toBe(true); + }); + + it("should return false for string without digits", () => { + expect(hasAnyDigits("")).toBe(false); + expect(hasAnyDigits("__/__/____")).toBe(false); + expect(hasAnyDigits("abc")).toBe(false); + }); + }); + + describe("Config constants", () => { + it("should have correct MaskedDateConfig", () => { + expect(MaskedDateConfig.placeholder).toBe("__/__/____"); + expect(MaskedDateConfig.displayFormat).toBe("DD/MM/YYYY"); + expect(MaskedDateConfig.internalFormat).toBe("YYYY-MM-DD"); + expect(MaskedDateConfig.mask).toBe("00/00/0000"); + }); + + it("should have correct MaskedDateTimeConfig", () => { + expect(MaskedDateTimeConfig.placeholder).toBe("__/__/____ __:__:__"); + expect(MaskedDateTimeConfig.displayFormat).toBe("DD/MM/YYYY HH:mm:ss"); + expect(MaskedDateTimeConfig.internalFormat).toBe("YYYY-MM-DD HH:mm:ss"); + expect(MaskedDateTimeConfig.mask).toBe("00/00/0000 00:00:00"); + }); + + it("should have correct MaskedTimeConfig", () => { + expect(MaskedTimeConfig.placeholder).toBe("__:__:__"); + expect(MaskedTimeConfig.displayFormat).toBe("HH:mm:ss"); + expect(MaskedTimeConfig.internalFormat).toBe("HH:mm:ss"); + expect(MaskedTimeConfig.mask).toBe("00:00:00"); + }); + }); +}); diff --git a/src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.ts b/src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.ts new file mode 100644 index 00000000..903759b7 --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.ts @@ -0,0 +1,244 @@ +import { Dayjs } from "dayjs"; +import dayjs from "@/helpers/dayjs"; + +export const MaskedDateConfig = { + placeholder: "__/__/____", + displayFormat: "DD/MM/YYYY", + internalFormat: "YYYY-MM-DD", + mask: "00/00/0000", +} as const; + +export const MaskedDateTimeConfig = { + placeholder: "__/__/____ __:__:__", + displayFormat: "DD/MM/YYYY HH:mm:ss", + internalFormat: "YYYY-MM-DD HH:mm:ss", + mask: "00/00/0000 00:00:00", +} as const; + +export const MaskedTimeConfig = { + placeholder: "__:__:__", + displayFormat: "HH:mm:ss", + internalFormat: "HH:mm:ss", + mask: "00:00:00", +} as const; + +export type AutocompleteResult = { + displayValue: string; + internalValue: string; +} | null; + +const parsePartialDate = ( + value: string, +): { day?: number; month?: number; year?: number } => { + const parts = value.split("/"); + return { + day: parts[0] ? parseInt(parts[0], 10) : undefined, + month: parts[1] ? parseInt(parts[1], 10) : undefined, + year: parts[2] ? parseInt(parts[2], 10) : undefined, + }; +}; + +const parsePartialTime = ( + value: string, +): { hours?: number; minutes?: number; seconds?: number } => { + const parts = value.split(":"); + return { + hours: parts[0] ? parseInt(parts[0], 10) : undefined, + minutes: parts[1] ? parseInt(parts[1], 10) : undefined, + seconds: parts[2] ? parseInt(parts[2], 10) : undefined, + }; +}; + +export const autocompleteDate = ( + inputValue: string, + now: Dayjs = dayjs(), +): AutocompleteResult => { + const trimmed = inputValue.trim(); + + if (!trimmed) { + const displayValue = now.format(MaskedDateConfig.displayFormat); + const internalValue = now.format(MaskedDateConfig.internalFormat); + return { displayValue, internalValue }; + } + + const { day, month, year } = parsePartialDate(trimmed); + + if (day === undefined || isNaN(day)) { + return null; + } + + const resultMonth = + month !== undefined && !isNaN(month) ? month - 1 : now.month(); + const resultYear = year !== undefined && !isNaN(year) ? year : now.year(); + + const result = dayjs() + .year(resultYear) + .month(resultMonth) + .date(day) + .startOf("day"); + + if (!result.isValid()) { + return null; + } + + return { + displayValue: result.format(MaskedDateConfig.displayFormat), + internalValue: result.format(MaskedDateConfig.internalFormat), + }; +}; + +export const autocompleteDateTime = ( + inputValue: string, + now: Dayjs = dayjs(), +): AutocompleteResult => { + const trimmed = inputValue.trim(); + + if (!trimmed) { + const displayValue = now.format(MaskedDateTimeConfig.displayFormat); + const internalValue = now.format(MaskedDateTimeConfig.internalFormat); + return { displayValue, internalValue }; + } + + const [datePart, timePart] = trimmed.split(" "); + const { day, month, year } = parsePartialDate(datePart || ""); + + if (day === undefined || isNaN(day)) { + return null; + } + + const resultMonth = + month !== undefined && !isNaN(month) ? month - 1 : now.month(); + const resultYear = year !== undefined && !isNaN(year) ? year : now.year(); + + let result = dayjs().year(resultYear).month(resultMonth).date(day); + + if (timePart) { + const { hours, minutes, seconds } = parsePartialTime(timePart); + if (hours !== undefined && !isNaN(hours)) { + result = result.hour(hours); + result = result.minute( + minutes !== undefined && !isNaN(minutes) ? minutes : now.minute(), + ); + result = result.second( + seconds !== undefined && !isNaN(seconds) ? seconds : now.second(), + ); + } else { + result = result + .hour(now.hour()) + .minute(now.minute()) + .second(now.second()); + } + } else { + result = result.hour(now.hour()).minute(now.minute()).second(now.second()); + } + + if (!result.isValid()) { + return null; + } + + return { + displayValue: result.format(MaskedDateTimeConfig.displayFormat), + internalValue: result.format(MaskedDateTimeConfig.internalFormat), + }; +}; + +export const autocompleteTime = ( + inputValue: string, + now: Dayjs = dayjs(), + useZeros = false, +): AutocompleteResult => { + const trimmed = inputValue.trim(); + + if (!trimmed) { + const displayValue = now.format(MaskedTimeConfig.displayFormat); + const internalValue = now.format(MaskedTimeConfig.internalFormat); + return { displayValue, internalValue }; + } + + const { hours, minutes, seconds } = parsePartialTime(trimmed); + + if (hours === undefined || isNaN(hours)) { + return null; + } + + const resultMinutes = + minutes !== undefined && !isNaN(minutes) + ? minutes + : useZeros + ? 0 + : now.minute(); + const resultSeconds = + seconds !== undefined && !isNaN(seconds) + ? seconds + : useZeros + ? 0 + : now.second(); + + const result = dayjs() + .hour(hours) + .minute(resultMinutes) + .second(resultSeconds); + + if (!result.isValid()) { + return null; + } + + return { + displayValue: result.format(MaskedTimeConfig.displayFormat), + internalValue: result.format(MaskedTimeConfig.internalFormat), + }; +}; + +export const parseInternalToDisplay = ( + value: string | undefined, + internalFormat: string, + displayFormat: string, + timezone?: string, +): string => { + if (!value) return ""; + + try { + const parsed = timezone + ? dayjs.tz(value, internalFormat, timezone) + : dayjs(value, internalFormat); + + if (!parsed.isValid()) return ""; + + return parsed.format(displayFormat); + } catch { + return ""; + } +}; + +export const parseDisplayToInternal = ( + value: string, + displayFormat: string, + internalFormat: string, + timezone?: string, +): string | null => { + if (!value || value.includes("_")) return null; + + try { + // Parse in the context of the specified timezone for consistency + const parsed = timezone + ? dayjs.tz(value, displayFormat, timezone) + : dayjs(value, displayFormat); + + if (!parsed.isValid()) return null; + + return parsed.format(internalFormat); + } catch { + return null; + } +}; + +export const isCompleteValue = ( + value: string, + placeholder: string, +): boolean => { + return value.length === placeholder.length && !value.includes("_"); +}; + +export const hasAnyDigits = (value: string): boolean => { + return /\d/.test(value); +}; diff --git a/src/components/widgets/Date/DateMaskedInput/index.ts b/src/components/widgets/Date/DateMaskedInput/index.ts new file mode 100644 index 00000000..7d73e5b0 --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/index.ts @@ -0,0 +1,5 @@ +export { DateMaskedInput } from "./DateMaskedInput"; +export type { + DateMaskedInputProps, + DateMaskedInputType, +} from "./DateMaskedInput.types"; diff --git a/src/components/widgets/Date/index.ts b/src/components/widgets/Date/index.ts index 9c694641..9b016c76 100644 --- a/src/components/widgets/Date/index.ts +++ b/src/components/widgets/Date/index.ts @@ -1,3 +1,4 @@ export * from "./DateInput"; export * from "./DateValue"; export * from "./DateTimeValue"; +export * from "./DateMaskedInput";