From 179f43b2001a0a545c35e02213194aa385688e65 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 8 Jan 2026 10:24:13 +0000 Subject: [PATCH 01/18] feat: add masked input components for date/time widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MaskedDateInput component with DD/MM/YYYY mask - Add MaskedDateTimeInput component with DD/MM/YYYY HH:mm:ss mask - Add MaskedTimeInput component with HH:mm:ss mask Features: - Input masking with auto-insert separators (using imask) - Autocomplete on Enter key and blur - Escape key commits value and moves to next focusable element - Clear value when input is empty - Optional calendar/clock popup buttons for visual selection - Required field styling - Error tooltip for invalid values Closes gisce/webclient#2291 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Marc Güell Segarra --- package-lock.json | 124 +++++--- package.json | 2 + .../Date/MaskedDateInput/MaskedDateInput.tsx | 267 +++++++++++++++++ .../MaskedDateInput/MaskedDateInput.types.ts | 10 + .../helpers/MaskedDate.helpers.ts | 240 ++++++++++++++++ .../widgets/Date/MaskedDateInput/index.ts | 3 + .../MaskedDateTimeInput.tsx | 272 ++++++++++++++++++ .../MaskedDateTimeInput.types.ts | 10 + .../widgets/Date/MaskedDateTimeInput/index.ts | 2 + .../Date/MaskedTimeInput/MaskedTimeInput.tsx | 253 ++++++++++++++++ .../MaskedTimeInput/MaskedTimeInput.types.ts | 10 + .../widgets/Date/MaskedTimeInput/index.ts | 2 + src/components/widgets/Date/index.ts | 3 + 13 files changed, 1155 insertions(+), 43 deletions(-) create mode 100644 src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx create mode 100644 src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts create mode 100644 src/components/widgets/Date/MaskedDateInput/helpers/MaskedDate.helpers.ts create mode 100644 src/components/widgets/Date/MaskedDateInput/index.ts create mode 100644 src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx create mode 100644 src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts create mode 100644 src/components/widgets/Date/MaskedDateTimeInput/index.ts create mode 100644 src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx create mode 100644 src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts create mode 100644 src/components/widgets/Date/MaskedTimeInput/index.ts diff --git a/package-lock.json b/package-lock.json index 5b953afe..379be882 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" @@ -286,7 +288,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 +2253,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 +3654,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", @@ -4685,7 +4697,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.9.1", @@ -4699,7 +4712,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.9.1", @@ -4712,7 +4726,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.9.1", @@ -4726,7 +4741,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.9.1", @@ -4740,7 +4756,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.9.1", @@ -4754,7 +4771,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.9.1", @@ -4768,7 +4786,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.9.1", @@ -4782,7 +4801,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.9.1", @@ -4796,7 +4816,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.9.1", @@ -4810,7 +4831,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.9.1", @@ -4824,7 +4846,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.9.1", @@ -4838,7 +4861,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.9.1", @@ -4852,7 +4876,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rushstack/node-core-library": { "version": "3.62.0", @@ -7249,7 +7274,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 +7634,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 +7694,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 +7821,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 +7855,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 +8289,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 +9116,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -10185,6 +10203,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 +10225,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 +10347,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 +10370,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 +11161,6 @@ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11236,7 +11261,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 +11460,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 +11522,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 +11547,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 +12996,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 +13499,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 +15289,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 +19090,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" } @@ -19972,7 +20002,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 +20011,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 +20832,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 +20919,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 +20948,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 +21767,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 +22851,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 +23527,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 +23912,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..4af9ef29 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ }, "dependencies": { "@ant-design/icons": "^6.0.0", + "imask": "^7.6.0", + "react-imask": "^7.6.0", "@tabler/icons-react": "^3.31.0", "antd": "5.25.1", "classnames": "^2.5.1", diff --git a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx new file mode 100644 index 00000000..b3f37b3f --- /dev/null +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx @@ -0,0 +1,267 @@ +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { IMaskInput } from "react-imask"; +import { Button, DatePicker, Tooltip } from "antd"; +import { CalendarOutlined } from "@ant-design/icons"; +import dayjs, { Dayjs } from "dayjs"; +import styled from "styled-components"; +import { MaskedDateInputProps } from "./MaskedDateInput.types"; +import { + MaskedDateConfig, + autocompleteDate, + parseInternalToDisplay, + parseDisplayToInternal, + isCompleteValue, + hasAnyDigits, +} from "./helpers/MaskedDate.helpers"; +import { useRequiredStyle } from "@/hooks/useRequiredStyle"; +import { useDatePickerLocale } from "../DateInput/hooks/useDatePickerLocale"; + +const InputWrapper = styled.div` + display: flex; + align-items: center; + width: 100%; + gap: 4px; +`; + +const StyledInput = styled(IMaskInput)<{ + $hasError?: boolean; + $required?: React.CSSProperties; +}>` + flex: 1; + width: 100%; + height: 32px; + padding: 4px 11px; + font-size: 14px; + line-height: 1.5715; + color: rgba(0, 0, 0, 0.88); + background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; + border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; + border-radius: 6px; + transition: all 0.2s; + font-family: inherit; + + &:focus { + border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; + box-shadow: 0 0 0 2px + ${(props) => + props.$hasError ? "rgba(255, 77, 79, 0.1)" : "rgba(5, 145, 255, 0.1)"}; + outline: none; + } + + &:hover { + border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; + } + + &:disabled { + color: rgba(0, 0, 0, 0.25); + background-color: rgba(0, 0, 0, 0.04); + cursor: not-allowed; + } +`; + +const CalendarButton = styled(Button)` + flex-shrink: 0; +`; + +const HiddenPicker = styled(DatePicker)` + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; + height: 0; +`; + +const MaskedDateInput: React.FC = memo((props) => { + const { + value, + onChange, + id, + readOnly = false, + required = false, + timezone = "Europe/Madrid", + showCalendarButton = true, + placeholder = MaskedDateConfig.placeholder, + } = props; + + const inputRef = useRef(null); + const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( + null, + ); + const [calendarOpen, setCalendarOpen] = useState(false); + const [parseError, setParseError] = useState(null); + const [inputValue, setInputValue] = useState(""); + + const datePickerLocale = useDatePickerLocale(); + const requiredStyle = useRequiredStyle(required, readOnly); + + const displayValue = useMemo(() => { + if (!value) return ""; + return parseInternalToDisplay( + value, + MaskedDateConfig.internalFormat, + MaskedDateConfig.displayFormat, + timezone, + ); + }, [value, timezone]); + + const currentInputValue = inputValue || displayValue; + + const handleAccept = useCallback((maskedValue: string) => { + setInputValue(maskedValue); + setParseError(null); + }, []); + + const commitValue = useCallback( + (maskedValue: string) => { + if (!maskedValue || !hasAnyDigits(maskedValue)) { + onChange?.(null); + setInputValue(""); + setParseError(null); + return; + } + + if (isCompleteValue(maskedValue, placeholder)) { + const internalValue = parseDisplayToInternal( + maskedValue, + MaskedDateConfig.displayFormat, + MaskedDateConfig.internalFormat, + ); + if (internalValue) { + onChange?.(internalValue); + setInputValue(""); + setParseError(null); + } else { + setParseError("Invalid date"); + } + return; + } + + const autocompleted = autocompleteDate(maskedValue); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + } else { + setParseError("Invalid date format"); + } + }, + [onChange, placeholder], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + commitValue(input.value); + } else if (e.key === "Escape") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + + 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); + } + }, + [commitValue], + ); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + if (calendarOpen) return; + commitValue(e.target.value); + }, + [commitValue, calendarOpen], + ); + + const handleCalendarChange = useCallback( + (date: unknown, dateString: string | string[]) => { + setCalendarOpen(false); + if (date && dayjs.isDayjs(date)) { + const internalValue = date.format(MaskedDateConfig.internalFormat); + onChange?.(internalValue); + setInputValue(""); + setParseError(null); + } + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + }, + [onChange], + ); + + const handleCalendarClick = useCallback(() => { + setCalendarOpen(true); + }, []); + + const pickerValue = useMemo(() => { + if (!value) return undefined; + try { + const parsed = timezone + ? dayjs.tz(value, MaskedDateConfig.internalFormat, timezone) + : dayjs(value, MaskedDateConfig.internalFormat); + return parsed.isValid() ? parsed : undefined; + } catch { + return undefined; + } + }, [value, timezone]); + + return ( + + + + {showCalendarButton && !readOnly && ( + <> + } + onClick={handleCalendarClick} + size="middle" + /> + + + )} + + + ); +}); + +MaskedDateInput.displayName = "MaskedDateInput"; + +export { MaskedDateInput }; diff --git a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts new file mode 100644 index 00000000..a905f2bb --- /dev/null +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts @@ -0,0 +1,10 @@ +export type MaskedDateInputProps = { + value?: string; + onChange?: (value: string | null | undefined) => void; + id: string; + readOnly?: boolean; + required?: boolean; + timezone?: string; + showCalendarButton?: boolean; + placeholder?: string; +}; diff --git a/src/components/widgets/Date/MaskedDateInput/helpers/MaskedDate.helpers.ts b/src/components/widgets/Date/MaskedDateInput/helpers/MaskedDate.helpers.ts new file mode 100644 index 00000000..452fdda7 --- /dev/null +++ b/src/components/widgets/Date/MaskedDateInput/helpers/MaskedDate.helpers.ts @@ -0,0 +1,240 @@ +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, +): string | null => { + if (!value || value.includes("_")) return null; + + try { + const parsed = 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/MaskedDateInput/index.ts b/src/components/widgets/Date/MaskedDateInput/index.ts new file mode 100644 index 00000000..a7cb417a --- /dev/null +++ b/src/components/widgets/Date/MaskedDateInput/index.ts @@ -0,0 +1,3 @@ +export * from "./MaskedDateInput"; +export * from "./MaskedDateInput.types"; +export * from "./helpers/MaskedDate.helpers"; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx new file mode 100644 index 00000000..e90c30a7 --- /dev/null +++ b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx @@ -0,0 +1,272 @@ +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { IMaskInput } from "react-imask"; +import { Button, DatePicker, Tooltip } from "antd"; +import { CalendarOutlined } from "@ant-design/icons"; +import dayjs, { Dayjs } from "dayjs"; +import styled from "styled-components"; +import { MaskedDateTimeInputProps } from "./MaskedDateTimeInput.types"; +import { + MaskedDateTimeConfig, + autocompleteDateTime, + parseInternalToDisplay, + parseDisplayToInternal, + isCompleteValue, + hasAnyDigits, +} from "../MaskedDateInput/helpers/MaskedDate.helpers"; +import { useRequiredStyle } from "@/hooks/useRequiredStyle"; +import { useDatePickerLocale } from "../DateInput/hooks/useDatePickerLocale"; + +const InputWrapper = styled.div` + display: flex; + align-items: center; + width: 100%; + gap: 4px; +`; + +const StyledInput = styled(IMaskInput)<{ + $hasError?: boolean; + $required?: React.CSSProperties; +}>` + flex: 1; + width: 100%; + height: 32px; + padding: 4px 11px; + font-size: 14px; + line-height: 1.5715; + color: rgba(0, 0, 0, 0.88); + background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; + border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; + border-radius: 6px; + transition: all 0.2s; + font-family: inherit; + + &:focus { + border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; + box-shadow: 0 0 0 2px + ${(props) => + props.$hasError ? "rgba(255, 77, 79, 0.1)" : "rgba(5, 145, 255, 0.1)"}; + outline: none; + } + + &:hover { + border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; + } + + &:disabled { + color: rgba(0, 0, 0, 0.25); + background-color: rgba(0, 0, 0, 0.04); + cursor: not-allowed; + } +`; + +const CalendarButton = styled(Button)` + flex-shrink: 0; +`; + +const HiddenPicker = styled(DatePicker)` + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; + height: 0; +`; + +const MaskedDateTimeInput: React.FC = memo( + (props) => { + const { + value, + onChange, + id, + readOnly = false, + required = false, + timezone = "Europe/Madrid", + showCalendarButton = true, + placeholder = MaskedDateTimeConfig.placeholder, + } = props; + + const inputRef = useRef(null); + const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( + null, + ); + const [calendarOpen, setCalendarOpen] = useState(false); + const [parseError, setParseError] = useState(null); + const [inputValue, setInputValue] = useState(""); + + const datePickerLocale = useDatePickerLocale(); + const requiredStyle = useRequiredStyle(required, readOnly); + + const displayValue = useMemo(() => { + if (!value) return ""; + return parseInternalToDisplay( + value, + MaskedDateTimeConfig.internalFormat, + MaskedDateTimeConfig.displayFormat, + timezone, + ); + }, [value, timezone]); + + const currentInputValue = inputValue || displayValue; + + const handleAccept = useCallback((maskedValue: string) => { + setInputValue(maskedValue); + setParseError(null); + }, []); + + const commitValue = useCallback( + (maskedValue: string) => { + if (!maskedValue || !hasAnyDigits(maskedValue)) { + onChange?.(null); + setInputValue(""); + setParseError(null); + return; + } + + if (isCompleteValue(maskedValue, placeholder)) { + const internalValue = parseDisplayToInternal( + maskedValue, + MaskedDateTimeConfig.displayFormat, + MaskedDateTimeConfig.internalFormat, + ); + if (internalValue) { + onChange?.(internalValue); + setInputValue(""); + setParseError(null); + } else { + setParseError("Invalid date/time"); + } + return; + } + + const autocompleted = autocompleteDateTime(maskedValue); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + } else { + setParseError("Invalid date/time format"); + } + }, + [onChange, placeholder], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + commitValue(input.value); + } else if (e.key === "Escape") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + + 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); + } + }, + [commitValue], + ); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + if (calendarOpen) return; + commitValue(e.target.value); + }, + [commitValue, calendarOpen], + ); + + const handleCalendarChange = useCallback( + (date: unknown, dateString: string | string[]) => { + setCalendarOpen(false); + if (date && dayjs.isDayjs(date)) { + const internalValue = date.format( + MaskedDateTimeConfig.internalFormat, + ); + onChange?.(internalValue); + setInputValue(""); + setParseError(null); + } + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + }, + [onChange], + ); + + const handleCalendarClick = useCallback(() => { + setCalendarOpen(true); + }, []); + + const pickerValue = useMemo(() => { + if (!value) return undefined; + try { + const parsed = timezone + ? dayjs.tz(value, MaskedDateTimeConfig.internalFormat, timezone) + : dayjs(value, MaskedDateTimeConfig.internalFormat); + return parsed.isValid() ? parsed : undefined; + } catch { + return undefined; + } + }, [value, timezone]); + + return ( + + + + {showCalendarButton && !readOnly && ( + <> + } + onClick={handleCalendarClick} + size="middle" + /> + + + )} + + + ); + }, +); + +MaskedDateTimeInput.displayName = "MaskedDateTimeInput"; + +export { MaskedDateTimeInput }; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts new file mode 100644 index 00000000..cba68f26 --- /dev/null +++ b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts @@ -0,0 +1,10 @@ +export type MaskedDateTimeInputProps = { + value?: string; + onChange?: (value: string | null | undefined) => void; + id: string; + readOnly?: boolean; + required?: boolean; + timezone?: string; + showCalendarButton?: boolean; + placeholder?: string; +}; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/index.ts b/src/components/widgets/Date/MaskedDateTimeInput/index.ts new file mode 100644 index 00000000..ad92fe63 --- /dev/null +++ b/src/components/widgets/Date/MaskedDateTimeInput/index.ts @@ -0,0 +1,2 @@ +export * from "./MaskedDateTimeInput"; +export * from "./MaskedDateTimeInput.types"; diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx new file mode 100644 index 00000000..f3cd6275 --- /dev/null +++ b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx @@ -0,0 +1,253 @@ +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { IMaskInput } from "react-imask"; +import { Button, TimePicker, Tooltip } from "antd"; +import { ClockCircleOutlined } from "@ant-design/icons"; +import dayjs, { Dayjs } from "dayjs"; +import styled from "styled-components"; +import { MaskedTimeInputProps } from "./MaskedTimeInput.types"; +import { + MaskedTimeConfig, + autocompleteTime, + isCompleteValue, + hasAnyDigits, +} from "../MaskedDateInput/helpers/MaskedDate.helpers"; +import { useRequiredStyle } from "@/hooks/useRequiredStyle"; + +const InputWrapper = styled.div` + display: flex; + align-items: center; + width: 100%; + gap: 4px; +`; + +const StyledInput = styled(IMaskInput)<{ + $hasError?: boolean; + $required?: React.CSSProperties; +}>` + flex: 1; + width: 100%; + height: 32px; + padding: 4px 11px; + font-size: 14px; + line-height: 1.5715; + color: rgba(0, 0, 0, 0.88); + background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; + border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; + border-radius: 6px; + transition: all 0.2s; + font-family: inherit; + + &:focus { + border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; + box-shadow: 0 0 0 2px + ${(props) => + props.$hasError ? "rgba(255, 77, 79, 0.1)" : "rgba(5, 145, 255, 0.1)"}; + outline: none; + } + + &:hover { + border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; + } + + &:disabled { + color: rgba(0, 0, 0, 0.25); + background-color: rgba(0, 0, 0, 0.04); + cursor: not-allowed; + } +`; + +const ClockButton = styled(Button)` + flex-shrink: 0; +`; + +const HiddenPicker = styled(TimePicker)` + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; + height: 0; +`; + +const MaskedTimeInput: React.FC = memo((props) => { + const { + value, + onChange, + id, + readOnly = false, + required = false, + showClockButton = true, + placeholder = MaskedTimeConfig.placeholder, + useZeros = false, + } = props; + + const inputRef = useRef(null); + const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( + null, + ); + const [pickerOpen, setPickerOpen] = useState(false); + const [parseError, setParseError] = useState(null); + const [inputValue, setInputValue] = useState(""); + + const requiredStyle = useRequiredStyle(required, readOnly); + + const displayValue = useMemo(() => { + if (!value) return ""; + try { + const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); + return parsed.isValid() + ? parsed.format(MaskedTimeConfig.displayFormat) + : ""; + } catch { + return ""; + } + }, [value]); + + const currentInputValue = inputValue || displayValue; + + const handleAccept = useCallback((maskedValue: string) => { + setInputValue(maskedValue); + setParseError(null); + }, []); + + const commitValue = useCallback( + (maskedValue: string) => { + if (!maskedValue || !hasAnyDigits(maskedValue)) { + onChange?.(null); + setInputValue(""); + setParseError(null); + return; + } + + if (isCompleteValue(maskedValue, placeholder)) { + onChange?.(maskedValue); + setInputValue(""); + setParseError(null); + return; + } + + const autocompleted = autocompleteTime(maskedValue, dayjs(), useZeros); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + } else { + setParseError("Invalid time format"); + } + }, + [onChange, placeholder, useZeros], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + commitValue(input.value); + } else if (e.key === "Escape") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + + 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); + } + }, + [commitValue], + ); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + if (pickerOpen) return; + commitValue(e.target.value); + }, + [commitValue, pickerOpen], + ); + + const handlePickerChange = useCallback( + (time: Dayjs | null) => { + setPickerOpen(false); + if (time) { + const timeValue = time.format(MaskedTimeConfig.internalFormat); + onChange?.(timeValue); + setInputValue(""); + setParseError(null); + } + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + }, + [onChange], + ); + + const handleClockClick = useCallback(() => { + setPickerOpen(true); + }, []); + + const pickerValue = useMemo(() => { + if (!value) return undefined; + try { + const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); + return parsed.isValid() ? parsed : undefined; + } catch { + return undefined; + } + }, [value]); + + return ( + + + + {showClockButton && !readOnly && ( + <> + } + onClick={handleClockClick} + size="middle" + /> + + + )} + + + ); +}); + +MaskedTimeInput.displayName = "MaskedTimeInput"; + +export { MaskedTimeInput }; diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts new file mode 100644 index 00000000..77dac68e --- /dev/null +++ b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts @@ -0,0 +1,10 @@ +export type MaskedTimeInputProps = { + value?: string; + onChange?: (value: string | null | undefined) => void; + id?: string; + readOnly?: boolean; + required?: boolean; + showClockButton?: boolean; + placeholder?: string; + useZeros?: boolean; +}; diff --git a/src/components/widgets/Date/MaskedTimeInput/index.ts b/src/components/widgets/Date/MaskedTimeInput/index.ts new file mode 100644 index 00000000..4ffadf8b --- /dev/null +++ b/src/components/widgets/Date/MaskedTimeInput/index.ts @@ -0,0 +1,2 @@ +export * from "./MaskedTimeInput"; +export * from "./MaskedTimeInput.types"; diff --git a/src/components/widgets/Date/index.ts b/src/components/widgets/Date/index.ts index 9c694641..ee62335f 100644 --- a/src/components/widgets/Date/index.ts +++ b/src/components/widgets/Date/index.ts @@ -1,3 +1,6 @@ export * from "./DateInput"; export * from "./DateValue"; export * from "./DateTimeValue"; +export * from "./MaskedDateInput"; +export * from "./MaskedDateTimeInput"; +export * from "./MaskedTimeInput"; From 7bdebe9895cd592463b1434eb5eecbe9155c4261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 8 Jan 2026 11:46:45 +0100 Subject: [PATCH 02/18] chore: add storybook cases for testing --- package-lock.json | 66 +-- .../MaskedDateInput.stories.tsx | 351 ++++++++++++++++ .../MaskedDateTimeInput.stories.tsx | 379 ++++++++++++++++++ .../MaskedTimeInput.stories.tsx | 360 +++++++++++++++++ 4 files changed, 1129 insertions(+), 27 deletions(-) create mode 100644 src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx create mode 100644 src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx create mode 100644 src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx diff --git a/package-lock.json b/package-lock.json index 379be882..3337dc65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -288,6 +288,7 @@ "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", @@ -3654,6 +3655,7 @@ "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", @@ -4697,8 +4699,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.9.1", @@ -4712,8 +4713,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.9.1", @@ -4726,8 +4726,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.9.1", @@ -4741,8 +4740,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.9.1", @@ -4756,8 +4754,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.9.1", @@ -4771,8 +4768,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.9.1", @@ -4786,8 +4782,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.9.1", @@ -4801,8 +4796,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.9.1", @@ -4816,8 +4810,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.9.1", @@ -4831,8 +4824,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.9.1", @@ -4846,8 +4838,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.9.1", @@ -4861,8 +4852,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.9.1", @@ -4876,8 +4866,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rushstack/node-core-library": { "version": "3.62.0", @@ -7274,6 +7263,7 @@ "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", @@ -7634,6 +7624,7 @@ "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" } @@ -7694,6 +7685,7 @@ "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": "*" } @@ -7821,6 +7813,7 @@ "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", @@ -7855,6 +7848,7 @@ "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", @@ -8289,6 +8283,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9116,6 +9111,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -10225,6 +10221,7 @@ "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", @@ -10347,6 +10344,7 @@ "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" }, @@ -10370,7 +10368,8 @@ "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "peer": true }, "node_modules/de-indent": { "version": "1.0.2", @@ -11161,6 +11160,7 @@ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11261,6 +11261,7 @@ "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", @@ -11460,6 +11461,7 @@ "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", @@ -11522,6 +11524,7 @@ "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", @@ -11547,6 +11550,7 @@ "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" }, @@ -12996,6 +13000,7 @@ "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", @@ -15289,6 +15294,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz", "integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==", "dev": true, + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -20832,6 +20838,7 @@ "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" }, @@ -20919,6 +20926,7 @@ "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" @@ -21767,6 +21775,7 @@ "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", @@ -22851,6 +22860,7 @@ "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", @@ -23527,6 +23537,7 @@ "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" @@ -23912,6 +23923,7 @@ "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/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx new file mode 100644 index 00000000..8ff17de2 --- /dev/null +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx @@ -0,0 +1,351 @@ +import React, { useEffect, useState } from "react"; +import { Meta, StoryFn } from "@storybook/react"; +import { MaskedDateInput } from "./MaskedDateInput"; +import { Form, Typography } from "antd"; +import { MaskedDateInputProps } from "./MaskedDateInput.types"; + +const { Text, Paragraph } = Typography; + +type StoryArgs = MaskedDateInputProps & { + timezone?: string; +}; + +export default { + title: "Components/Widgets/Date/MaskedDateInput", + component: MaskedDateInput, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +## MaskedDateInput + +A date input with DD/MM/YYYY mask that supports **partial entry with autocomplete**. + +### Key Features (from issue gisce/webclient#2291): + +1. **Masked Input**: Shows DD/MM/YYYY placeholder, guiding users on the expected format +2. **Partial Entry**: Users can enter just the day, or day/month - missing parts autocomplete from current date +3. **Smart Autocomplete**: + - Type "15" → autocompletes to "15/[current month]/[current year]" + - Type "15/06" → autocompletes to "15/06/[current year]" + - Press Enter or blur to trigger autocomplete +4. **Calendar Picker**: Click the calendar button for visual date selection +5. **Validation**: Invalid dates show error tooltip +6. **Keyboard Support**: Enter commits value, Escape commits and moves focus to next element + `, + }, + }, + }, + argTypes: { + timezone: { + control: "select", + options: [ + "UTC", + "Europe/Madrid", + "Europe/London", + "America/New_York", + "Asia/Tokyo", + "Australia/Sydney", + ], + description: "Timezone for the date picker", + defaultValue: "Europe/Madrid", + }, + required: { + control: "boolean", + description: "Whether the field is required (shows yellow background)", + defaultValue: false, + }, + readOnly: { + control: "boolean", + description: "Whether the field is read-only", + defaultValue: false, + }, + showCalendarButton: { + control: "boolean", + description: "Whether to show the calendar button", + defaultValue: true, + }, + }, +} as Meta; + +const Template: 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: +
+            Internal value: {currentValue || "(empty)"}
+            timezone: {args.timezone}
+            required: {args.required?.toString()}
+            readOnly: {args.readOnly?.toString()}
+          
+
+
+
+ ); +}; + +const AutocompleteDemo: StoryFn = (args) => { + const [form] = Form.useForm(); + const [value1, setValue1] = useState(); + const [value2, setValue2] = useState(); + const [value3, setValue3] = useState(); + + return ( +
+ + Try these autocomplete scenarios: + + +
+ + Type just day (e.g., "15") + + {" "} + → autocompletes month/year from today + + + } + > + setValue1(v || undefined)} + timezone="Europe/Madrid" + /> + {value1 && ( + + Result: {value1} + + )} + + + + Type day/month (e.g., "15/06") + → autocompletes year from today + + } + > + setValue2(v || undefined)} + timezone="Europe/Madrid" + /> + {value2 && ( + + Result: {value2} + + )} + + + + Type full date (e.g., "15/06/2024") + → no autocomplete needed + + } + > + setValue3(v || undefined)} + timezone="Europe/Madrid" + /> + {value3 && ( + + Result: {value3} + + )} + +
+ +
+ + Tip: Press Enter or click outside the field to + trigger autocomplete. Invalid dates will show an error tooltip. + +
+
+ ); +}; + +// Main demo showing autocomplete functionality +export const AutocompleteFeature = AutocompleteDemo.bind({}); +AutocompleteFeature.args = { + timezone: "Europe/Madrid", +}; +AutocompleteFeature.parameters = { + docs: { + description: { + story: + "Demonstrates the key usability improvement: users can enter partial dates and the component autocompletes missing parts from the current date.", + }, + }, +}; + +// Basic usage with DD/MM/YYYY mask +export const Basic = Template.bind({}); +Basic.args = { + id: "basic-masked-date", + value: "2024-03-10", + required: false, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; + +// Empty state showing the mask placeholder +export const EmptyWithMask = Template.bind({}); +EmptyWithMask.args = { + id: "empty-masked-date", + value: undefined, + required: false, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; +EmptyWithMask.parameters = { + docs: { + description: { + story: + "Shows the DD/MM/YYYY mask placeholder when empty, guiding users on the expected format.", + }, + }, +}; + +// Required field with yellow background +export const Required = Template.bind({}); +Required.args = { + id: "required-masked-date", + value: "2024-03-10", + required: true, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; +Required.parameters = { + docs: { + description: { + story: + "Required fields show a yellow background to indicate they must be filled.", + }, + }, +}; + +// Read-only field +export const ReadOnly = Template.bind({}); +ReadOnly.args = { + id: "readonly-masked-date", + value: "2024-03-10", + required: false, + readOnly: true, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; +ReadOnly.parameters = { + docs: { + description: { + story: "Read-only mode disables the input and hides the calendar button.", + }, + }, +}; + +// Without calendar button - input only +export const WithoutCalendarButton = Template.bind({}); +WithoutCalendarButton.args = { + id: "no-calendar-masked-date", + value: "2024-03-10", + required: false, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: false, +}; +WithoutCalendarButton.parameters = { + docs: { + description: { + story: + "The calendar button can be hidden for a more compact interface, relying only on keyboard input.", + }, + }, +}; + +// Different timezone - UTC +export const TimezoneUTC = Template.bind({}); +TimezoneUTC.args = { + id: "utc-masked-date", + value: "2024-03-10", + required: false, + readOnly: false, + timezone: "UTC", + showCalendarButton: true, +}; + +// Different timezone - Tokyo +export const TimezoneTokyo = Template.bind({}); +TimezoneTokyo.args = { + id: "tokyo-masked-date", + value: "2024-03-10", + required: false, + readOnly: false, + timezone: "Asia/Tokyo", + showCalendarButton: true, +}; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx new file mode 100644 index 00000000..f9a2a777 --- /dev/null +++ b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx @@ -0,0 +1,379 @@ +import React, { useEffect, useState } from "react"; +import { Meta, StoryFn } from "@storybook/react"; +import { MaskedDateTimeInput } from "./MaskedDateTimeInput"; +import { Form, Typography } from "antd"; +import { MaskedDateTimeInputProps } from "./MaskedDateTimeInput.types"; + +const { Text, Paragraph } = Typography; + +type StoryArgs = MaskedDateTimeInputProps & { + timezone?: string; +}; + +export default { + title: "Components/Widgets/Date/MaskedDateTimeInput", + component: MaskedDateTimeInput, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +## MaskedDateTimeInput + +A datetime input with DD/MM/YYYY HH:mm:ss mask that supports **partial entry with autocomplete**. + +### Key Features (from issue gisce/webclient#2291): + +1. **Masked Input**: Shows DD/MM/YYYY HH:mm:ss placeholder +2. **Partial Entry**: Users can enter just the date part, or partial time - missing parts autocomplete +3. **Smart Autocomplete**: + - Type "15" → autocompletes to "15/[current month]/[current year] [current time]" + - Type "15/06" → autocompletes to "15/06/[current year] [current time]" + - Type "15/06/2024 14" → autocompletes to "15/06/2024 14:[current min]:[current sec]" + - Type "15/06/2024 14:30" → autocompletes to "15/06/2024 14:30:[current sec]" +4. **Calendar Picker**: Click the calendar button for visual date/time selection +5. **Validation**: Invalid dates show error tooltip + +### This solves the usability issue: +Users no longer need to select all datetime fields just to change one value. +For example, to change only the minutes, they can type the hour and minutes, +and the seconds will autocomplete from the current time. + `, + }, + }, + }, + argTypes: { + timezone: { + control: "select", + options: [ + "UTC", + "Europe/Madrid", + "Europe/London", + "America/New_York", + "Asia/Tokyo", + "Australia/Sydney", + ], + description: "Timezone for the datetime picker", + defaultValue: "Europe/Madrid", + }, + required: { + control: "boolean", + description: "Whether the field is required (shows yellow background)", + defaultValue: false, + }, + readOnly: { + control: "boolean", + description: "Whether the field is read-only", + defaultValue: false, + }, + showCalendarButton: { + control: "boolean", + description: "Whether to show the calendar button", + defaultValue: true, + }, + }, +} as Meta; + +const Template: StoryFn = (args) => { + const [form] = Form.useForm(); + const fieldName = args.id || "dateTimeField"; + 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: +
+            Internal value: {currentValue || "(empty)"}
+            timezone: {args.timezone}
+            required: {args.required?.toString()}
+            readOnly: {args.readOnly?.toString()}
+          
+
+
+
+ ); +}; + +const AutocompleteDemo: StoryFn = (args) => { + const [form] = Form.useForm(); + const [value1, setValue1] = useState(); + const [value2, setValue2] = useState(); + const [value3, setValue3] = useState(); + const [value4, setValue4] = useState(); + + return ( +
+ + + Try these autocomplete scenarios (solves gisce/webclient#2291): + + + +
+ + Type just day (e.g., "15") + + {" "} + → autocompletes month/year/time from now + + + } + > + setValue1(v || undefined)} + timezone="Europe/Madrid" + /> + {value1 && ( + + Result: {value1} + + )} + + + + Type day/month (e.g., "15/06") + → autocompletes year/time from now + + } + > + setValue2(v || undefined)} + timezone="Europe/Madrid" + /> + {value2 && ( + + Result: {value2} + + )} + + + + Type date + hour (e.g., "15/06/2024 14") + → autocompletes min:sec + + } + > + setValue3(v || undefined)} + timezone="Europe/Madrid" + /> + {value3 && ( + + Result: {value3} + + )} + + + + Type date + hour:min (e.g., "15/06/2024 14:30") + → autocompletes seconds + + } + > + setValue4(v || undefined)} + timezone="Europe/Madrid" + /> + {value4 && ( + + Result: {value4} + + )} + +
+ +
+ + Usability improvement: You no longer need to fill in + all fields. Just change what you need and the rest autocompletes from + current date/time. + +
+
+ ); +}; + +// Main demo showing autocomplete functionality - the key usability improvement +export const AutocompleteFeature = AutocompleteDemo.bind({}); +AutocompleteFeature.args = { + timezone: "Europe/Madrid", +}; +AutocompleteFeature.parameters = { + docs: { + description: { + story: + "**Main feature**: Demonstrates partial entry with autocomplete. Users can change just the fields they need without filling everything.", + }, + }, +}; + +// Basic usage with DD/MM/YYYY HH:mm:ss mask +export const Basic = Template.bind({}); +Basic.args = { + id: "basic-masked-datetime", + value: "2024-03-10 14:30:00", + required: false, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; + +// Empty state showing the mask placeholder +export const EmptyWithMask = Template.bind({}); +EmptyWithMask.args = { + id: "empty-masked-datetime", + value: undefined, + required: false, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; +EmptyWithMask.parameters = { + docs: { + description: { + story: "Shows the DD/MM/YYYY HH:mm:ss mask placeholder when empty.", + }, + }, +}; + +// Required field with yellow background +export const Required = Template.bind({}); +Required.args = { + id: "required-masked-datetime", + value: "2024-03-10 14:30:00", + required: true, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; + +// Read-only field +export const ReadOnly = Template.bind({}); +ReadOnly.args = { + id: "readonly-masked-datetime", + value: "2024-03-10 14:30:00", + required: false, + readOnly: true, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; + +// Without calendar button +export const WithoutCalendarButton = Template.bind({}); +WithoutCalendarButton.args = { + id: "no-calendar-masked-datetime", + value: "2024-03-10 14:30:00", + required: false, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: false, +}; + +// Different timezone - UTC +export const TimezoneUTC = Template.bind({}); +TimezoneUTC.args = { + id: "utc-masked-datetime", + value: "2024-03-10 14:30:00", + required: false, + readOnly: false, + timezone: "UTC", + showCalendarButton: true, +}; + +// Different timezone - Tokyo +export const TimezoneTokyo = Template.bind({}); +TimezoneTokyo.args = { + id: "tokyo-masked-datetime", + value: "2024-03-10 14:30:00", + required: false, + readOnly: false, + timezone: "Asia/Tokyo", + showCalendarButton: true, +}; + +// Midnight time +export const Midnight = Template.bind({}); +Midnight.args = { + id: "midnight-masked-datetime", + value: "2024-03-10 00:00:00", + required: false, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; + +// End of day +export const EndOfDay = Template.bind({}); +EndOfDay.args = { + id: "endofday-masked-datetime", + value: "2024-03-10 23:59:59", + required: false, + readOnly: false, + timezone: "Europe/Madrid", + showCalendarButton: true, +}; diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx new file mode 100644 index 00000000..09e7e713 --- /dev/null +++ b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx @@ -0,0 +1,360 @@ +import React, { useEffect, useState } from "react"; +import { Meta, StoryFn } from "@storybook/react"; +import { MaskedTimeInput } from "./MaskedTimeInput"; +import { Form, Typography } from "antd"; +import { MaskedTimeInputProps } from "./MaskedTimeInput.types"; + +const { Text, Paragraph } = Typography; + +type StoryArgs = MaskedTimeInputProps; + +export default { + title: "Components/Widgets/Date/MaskedTimeInput", + component: MaskedTimeInput, + parameters: { + layout: "centered", + docs: { + description: { + component: ` +## MaskedTimeInput + +A time input with HH:mm:ss mask that supports **partial entry with autocomplete**. + +### Key Features (from issue gisce/webclient#2291): + +1. **Masked Input**: Shows HH:mm:ss placeholder +2. **Partial Entry**: Users can enter just hours, or hours:minutes - missing parts autocomplete +3. **Smart Autocomplete**: + - Type "14" → autocompletes to "14:[current min]:[current sec]" + - Type "14:30" → autocompletes to "14:30:[current sec]" +4. **useZeros Option**: When enabled, autocompletes with zeros instead of current time + - Type "14" with useZeros → "14:00:00" +5. **Clock Picker**: Click the clock button for visual time selection +6. **Validation**: Invalid times show error tooltip + +### This solves the usability issue: +Users no longer need to select hours, minutes AND seconds just to change one value. +For example, to change only the minutes, they type hour:minutes and seconds autocomplete. + `, + }, + }, + }, + argTypes: { + required: { + control: "boolean", + description: "Whether the field is required (shows yellow background)", + defaultValue: false, + }, + readOnly: { + control: "boolean", + description: "Whether the field is read-only", + defaultValue: false, + }, + showClockButton: { + control: "boolean", + description: "Whether to show the clock button", + defaultValue: true, + }, + useZeros: { + control: "boolean", + description: + "When autocompleting, fill missing parts with zeros instead of current time", + defaultValue: false, + }, + }, +} as Meta; + +const Template: StoryFn = (args) => { + const [form] = Form.useForm(); + const fieldName = args.id || "timeField"; + 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: +
+            Internal value: {currentValue || "(empty)"}
+            required: {args.required?.toString()}
+            readOnly: {args.readOnly?.toString()}
+            useZeros: {args.useZeros?.toString()}
+          
+
+
+
+ ); +}; + +const AutocompleteDemo: StoryFn = (args) => { + const [form] = Form.useForm(); + const [value1, setValue1] = useState(); + const [value2, setValue2] = useState(); + const [value3, setValue3] = useState(); + const [value4, setValue4] = useState(); + + return ( +
+ + + Try these autocomplete scenarios (solves gisce/webclient#2291): + + + +
+ + Type just hour (e.g., "14") + → autocompletes min:sec from now + + } + > + setValue1(v || undefined)} + useZeros={false} + /> + {value1 && ( + + Result: {value1} + + )} + + + + Type hour:min (e.g., "14:30") + → autocompletes seconds from now + + } + > + setValue2(v || undefined)} + useZeros={false} + /> + {value2 && ( + + Result: {value2} + + )} + + + + With useZeros: Type "14" + → autocompletes to "14:00:00" + + } + > + setValue3(v || undefined)} + useZeros={true} + /> + {value3 && ( + + Result: {value3} + + )} + + + + Full time (e.g., "14:30:45") + → no autocomplete needed + + } + > + setValue4(v || undefined)} + /> + {value4 && ( + + Result: {value4} + + )} + +
+ +
+ + Usability improvement: You no longer need to fill in + hours, minutes, AND seconds. Just change what you need and the rest + autocompletes. + +
+
+ ); +}; + +// Main demo showing autocomplete functionality - the key usability improvement +export const AutocompleteFeature = AutocompleteDemo.bind({}); +AutocompleteFeature.args = {}; +AutocompleteFeature.parameters = { + docs: { + description: { + story: + "**Main feature**: Demonstrates partial entry with autocomplete. Users can change just the fields they need without filling everything.", + }, + }, +}; + +// Basic usage with HH:mm:ss mask +export const Basic = Template.bind({}); +Basic.args = { + id: "basic-masked-time", + value: "14:30:00", + required: false, + readOnly: false, + showClockButton: true, + useZeros: false, +}; + +// Empty state showing the mask placeholder +export const EmptyWithMask = Template.bind({}); +EmptyWithMask.args = { + id: "empty-masked-time", + value: undefined, + required: false, + readOnly: false, + showClockButton: true, + useZeros: false, +}; +EmptyWithMask.parameters = { + docs: { + description: { + story: "Shows the HH:mm:ss mask placeholder when empty.", + }, + }, +}; + +// Required field with yellow background +export const Required = Template.bind({}); +Required.args = { + id: "required-masked-time", + value: "14:30:00", + required: true, + readOnly: false, + showClockButton: true, + useZeros: false, +}; + +// Read-only field +export const ReadOnly = Template.bind({}); +ReadOnly.args = { + id: "readonly-masked-time", + value: "14:30:00", + required: false, + readOnly: true, + showClockButton: true, + useZeros: false, +}; + +// Without clock button +export const WithoutClockButton = Template.bind({}); +WithoutClockButton.args = { + id: "no-clock-masked-time", + value: "14:30:00", + required: false, + readOnly: false, + showClockButton: false, + useZeros: false, +}; + +// With useZeros - autocomplete fills with zeros instead of current time +export const WithUseZeros = Template.bind({}); +WithUseZeros.args = { + id: "zeros-masked-time", + value: undefined, + required: false, + readOnly: false, + showClockButton: true, + useZeros: true, +}; +WithUseZeros.parameters = { + docs: { + description: { + story: + "With useZeros enabled, partial entries autocomplete with :00 instead of current time. Try typing just '14' and pressing Enter.", + }, + }, +}; + +// Midnight time +export const Midnight = Template.bind({}); +Midnight.args = { + id: "midnight-masked-time", + value: "00:00:00", + required: false, + readOnly: false, + showClockButton: true, + useZeros: false, +}; + +// End of day +export const EndOfDay = Template.bind({}); +EndOfDay.args = { + id: "endofday-masked-time", + value: "23:59:59", + required: false, + readOnly: false, + showClockButton: true, + useZeros: false, +}; From 351f8a429b2293dc3113e0b7cac91731fe2380ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Jan 2026 10:55:49 +0000 Subject: [PATCH 03/18] feat: add double-click handler and improved storybook docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add onDoubleClick handler to MaskedDateInput, MaskedDateTimeInput, and MaskedTimeInput - Double-click now commits value (same as Enter key) per issue gisce/webclient#2291 - Update MaskedDateInput.stories.tsx with comprehensive keyboard behavior demo - Add keyboard/mouse behavior documentation to component description Closes gisce/webclient#2291 Co-authored-by: Marc Güell Segarra --- package-lock.json | 66 +++--- .../MaskedDateInput.stories.tsx | 203 +++++++++++++++++- .../Date/MaskedDateInput/MaskedDateInput.tsx | 9 + .../MaskedDateTimeInput.tsx | 9 + .../Date/MaskedTimeInput/MaskedTimeInput.tsx | 9 + 5 files changed, 255 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3337dc65..379be882 100644 --- a/package-lock.json +++ b/package-lock.json @@ -288,7 +288,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", @@ -3655,7 +3654,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", @@ -4699,7 +4697,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.9.1", @@ -4713,7 +4712,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.9.1", @@ -4726,7 +4726,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.9.1", @@ -4740,7 +4741,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.9.1", @@ -4754,7 +4756,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.9.1", @@ -4768,7 +4771,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.9.1", @@ -4782,7 +4786,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.9.1", @@ -4796,7 +4801,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.9.1", @@ -4810,7 +4816,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.9.1", @@ -4824,7 +4831,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.9.1", @@ -4838,7 +4846,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.9.1", @@ -4852,7 +4861,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.9.1", @@ -4866,7 +4876,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rushstack/node-core-library": { "version": "3.62.0", @@ -7263,7 +7274,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", @@ -7624,7 +7634,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" } @@ -7685,7 +7694,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": "*" } @@ -7813,7 +7821,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", @@ -7848,7 +7855,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", @@ -8283,7 +8289,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9111,7 +9116,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -10221,7 +10225,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", @@ -10344,7 +10347,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" }, @@ -10368,8 +10370,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", @@ -11160,7 +11161,6 @@ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11261,7 +11261,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", @@ -11461,7 +11460,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", @@ -11524,7 +11522,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", @@ -11550,7 +11547,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" }, @@ -13000,7 +12996,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", @@ -15294,7 +15289,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz", "integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==", "dev": true, - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -20838,7 +20832,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" }, @@ -20926,7 +20919,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" @@ -21775,7 +21767,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", @@ -22860,7 +22851,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", @@ -23537,7 +23527,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" @@ -23923,7 +23912,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/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx index 8ff17de2..f00f0d09 100644 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx @@ -29,10 +29,19 @@ A date input with DD/MM/YYYY mask that supports **partial entry with autocomplet 3. **Smart Autocomplete**: - Type "15" → autocompletes to "15/[current month]/[current year]" - Type "15/06" → autocompletes to "15/06/[current year]" - - Press Enter or blur to trigger autocomplete + - Triggers: Enter key, blur, or double-click 4. **Calendar Picker**: Click the calendar button for visual date selection 5. **Validation**: Invalid dates show error tooltip -6. **Keyboard Support**: Enter commits value, Escape commits and moves focus to next element + +### Keyboard & Mouse Behaviors: + +| Action | Behavior | +|--------|----------| +| **Enter** | Autocompletes partial value and commits | +| **Escape** | Commits current value and moves focus to next element | +| **Blur** | Autocompletes partial value and commits | +| **Double-click** | Autocompletes partial value and commits (same as Enter) | +| **Delete all + blur** | Clears the value (sets to null) | `, }, }, @@ -349,3 +358,193 @@ TimezoneTokyo.args = { timezone: "Asia/Tokyo", showCalendarButton: true, }; + +// Comprehensive keyboard behavior demo +const KeyboardBehaviorDemo: StoryFn = () => { + const [values, setValues] = useState<{ + enter: string | undefined; + escape: string | undefined; + blur: string | undefined; + doubleClick: string | undefined; + clear: string | undefined; + }>({ + enter: undefined, + escape: undefined, + blur: undefined, + doubleClick: undefined, + clear: "2024-06-15", + }); + + const [logs, setLogs] = useState([]); + + const addLog = (message: string) => { + setLogs((prev) => [ + ...prev.slice(-9), + `${new Date().toLocaleTimeString()}: ${message}`, + ]); + }; + + return ( +
+ + + Keyboard & Mouse Behavior Testing + + + + Test each input to verify the behavior described. Watch the event log + below. + + +
+ + 1. Enter Key + - Type "23" then press Enter + + } + > + { + setValues((prev) => ({ ...prev, enter: v || undefined })); + addLog(`Enter field: value set to "${v}"`); + }} + timezone="Europe/Madrid" + /> + {values.enter && → {values.enter}} + + + + 2. Escape Key + + {" "} + - Type "15/06" then press Escape (focus moves to next input) + + + } + > + { + setValues((prev) => ({ ...prev, escape: v || undefined })); + addLog(`Escape field: value set to "${v}"`); + }} + timezone="Europe/Madrid" + /> + {values.escape && → {values.escape}} + + + + 3. Blur (click outside) + - Type "10" then click outside + + } + > + { + setValues((prev) => ({ ...prev, blur: v || undefined })); + addLog(`Blur field: value set to "${v}"`); + }} + timezone="Europe/Madrid" + /> + {values.blur && → {values.blur}} + + + + 4. Double-click + - Type "25/12" then double-click + + } + > + { + setValues((prev) => ({ ...prev, doubleClick: v || undefined })); + addLog(`Double-click field: value set to "${v}"`); + }} + timezone="Europe/Madrid" + /> + {values.doubleClick && ( + → {values.doubleClick} + )} + + + + 5. Clear value + + {" "} + - Delete all content and click outside (should become null) + + + } + > + { + setValues((prev) => ({ ...prev, clear: v || undefined })); + addLog(`Clear field: value set to "${v ?? "null"}"`); + }} + timezone="Europe/Madrid" + /> + + {" "} + → {values.clear || "(empty/null)"} + + +
+ +
+ Event Log: + {logs.length === 0 ? ( +
No events yet...
+ ) : ( + logs.map((log, i) =>
{log}
) + )} +
+
+ ); +}; + +export const KeyboardBehaviors = KeyboardBehaviorDemo.bind({}); +KeyboardBehaviors.parameters = { + docs: { + description: { + story: ` +Interactive demo to test all keyboard and mouse behaviors: + +1. **Enter**: Autocompletes partial date and commits +2. **Escape**: Commits and moves focus to next focusable element +3. **Blur**: Autocompletes partial date and commits +4. **Double-click**: Same as Enter - autocompletes and commits +5. **Clear**: Delete all content and blur to clear the value + `, + }, + }, +}; diff --git a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx index b3f37b3f..80acbe50 100644 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx @@ -176,6 +176,14 @@ const MaskedDateInput: React.FC = memo((props) => { [commitValue], ); + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const input = e.target as HTMLInputElement; + commitValue(input.value); + }, + [commitValue], + ); + const handleBlur = useCallback( (e: React.FocusEvent) => { if (calendarOpen) return; @@ -234,6 +242,7 @@ const MaskedDateInput: React.FC = memo((props) => { onAccept={handleAccept} onKeyDown={handleKeyDown} onBlur={handleBlur} + onDoubleClick={handleDoubleClick} disabled={readOnly} $hasError={!!parseError} $required={requiredStyle} diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx index e90c30a7..81b94699 100644 --- a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx +++ b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx @@ -177,6 +177,14 @@ const MaskedDateTimeInput: React.FC = memo( [commitValue], ); + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const input = e.target as HTMLInputElement; + commitValue(input.value); + }, + [commitValue], + ); + const handleBlur = useCallback( (e: React.FocusEvent) => { if (calendarOpen) return; @@ -237,6 +245,7 @@ const MaskedDateTimeInput: React.FC = memo( onAccept={handleAccept} onKeyDown={handleKeyDown} onBlur={handleBlur} + onDoubleClick={handleDoubleClick} disabled={readOnly} $hasError={!!parseError} $required={requiredStyle} diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx index f3cd6275..846c8b52 100644 --- a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx +++ b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx @@ -165,6 +165,14 @@ const MaskedTimeInput: React.FC = memo((props) => { [commitValue], ); + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const input = e.target as HTMLInputElement; + commitValue(input.value); + }, + [commitValue], + ); + const handleBlur = useCallback( (e: React.FocusEvent) => { if (pickerOpen) return; @@ -221,6 +229,7 @@ const MaskedTimeInput: React.FC = memo((props) => { onAccept={handleAccept} onKeyDown={handleKeyDown} onBlur={handleBlur} + onDoubleClick={handleDoubleClick} disabled={readOnly} $hasError={!!parseError} $required={requiredStyle} From ffff8442de65c5c78406fe8a2933024dff67f8e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Jan 2026 11:09:49 +0000 Subject: [PATCH 04/18] fix: autocomplete to current date on enter with empty input, integrated icon style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When pressing Enter/Escape/Double-click on an empty masked input, it now autocompletes to the current date/time instead of setting null - Changed visual style from separate button to integrated suffix icon inside the input (matching antd's DatePicker style) - Updated Storybook with "Enter on Empty" test case Closes gisce/webclient#2291 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Marc Güell Segarra --- .../MaskedDateInput.stories.tsx | 56 +++++++++++++----- .../Date/MaskedDateInput/MaskedDateInput.tsx | 59 +++++++++++++------ .../MaskedDateTimeInput.tsx | 59 +++++++++++++------ .../Date/MaskedTimeInput/MaskedTimeInput.tsx | 59 +++++++++++++------ 4 files changed, 165 insertions(+), 68 deletions(-) diff --git a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx index f00f0d09..ffb8a475 100644 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx @@ -37,10 +37,10 @@ A date input with DD/MM/YYYY mask that supports **partial entry with autocomplet | Action | Behavior | |--------|----------| -| **Enter** | Autocompletes partial value and commits | -| **Escape** | Commits current value and moves focus to next element | -| **Blur** | Autocompletes partial value and commits | -| **Double-click** | Autocompletes partial value and commits (same as Enter) | +| **Enter** | Autocompletes partial value and commits. On empty input, fills current date. | +| **Escape** | Commits current value and moves focus to next element. On empty input, fills current date. | +| **Blur** | Commits current value (no autocomplete on empty - use Enter/Escape for that) | +| **Double-click** | Autocompletes partial value and commits. On empty input, fills current date. | | **Delete all + blur** | Clears the value (sets to null) | `, }, @@ -362,12 +362,14 @@ TimezoneTokyo.args = { // Comprehensive keyboard behavior demo const KeyboardBehaviorDemo: StoryFn = () => { const [values, setValues] = useState<{ + enterEmpty: string | undefined; enter: string | undefined; escape: string | undefined; blur: string | undefined; doubleClick: string | undefined; clear: string | undefined; }>({ + enterEmpty: undefined, enter: undefined, escape: undefined, blur: undefined, @@ -400,7 +402,32 @@ const KeyboardBehaviorDemo: StoryFn = () => { - 1. Enter Key + 1. Enter on Empty + + {" "} + - Focus and press Enter (should fill current date) + + + } + > + { + setValues((prev) => ({ ...prev, enterEmpty: v || undefined })); + addLog(`Enter empty field: value set to "${v}"`); + }} + timezone="Europe/Madrid" + /> + {values.enterEmpty && ( + → {values.enterEmpty} + )} + + + + 2. Enter Key (partial) - Type "23" then press Enter } @@ -420,7 +447,7 @@ const KeyboardBehaviorDemo: StoryFn = () => { - 2. Escape Key + 3. Escape Key {" "} - Type "15/06" then press Escape (focus moves to next input) @@ -443,7 +470,7 @@ const KeyboardBehaviorDemo: StoryFn = () => { - 3. Blur (click outside) + 4. Blur (click outside) - Type "10" then click outside } @@ -463,7 +490,7 @@ const KeyboardBehaviorDemo: StoryFn = () => { - 4. Double-click + 5. Double-click - Type "25/12" then double-click } @@ -485,7 +512,7 @@ const KeyboardBehaviorDemo: StoryFn = () => { - 5. Clear value + 6. Clear value {" "} - Delete all content and click outside (should become null) @@ -539,11 +566,12 @@ KeyboardBehaviors.parameters = { story: ` Interactive demo to test all keyboard and mouse behaviors: -1. **Enter**: Autocompletes partial date and commits -2. **Escape**: Commits and moves focus to next focusable element -3. **Blur**: Autocompletes partial date and commits -4. **Double-click**: Same as Enter - autocompletes and commits -5. **Clear**: Delete all content and blur to clear the value +1. **Enter on Empty**: Focus empty input and press Enter - fills current date +2. **Enter (partial)**: Autocompletes partial date and commits +3. **Escape**: Commits and moves focus to next focusable element +4. **Blur**: Commits partial date (blur on empty clears value) +5. **Double-click**: Same as Enter - autocompletes and commits +6. **Clear**: Delete all content and blur to clear the value `, }, }, diff --git a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx index 80acbe50..6b2fce46 100644 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx @@ -1,6 +1,6 @@ import { memo, useCallback, useMemo, useRef, useState } from "react"; import { IMaskInput } from "react-imask"; -import { Button, DatePicker, Tooltip } from "antd"; +import { DatePicker, Tooltip } from "antd"; import { CalendarOutlined } from "@ant-design/icons"; import dayjs, { Dayjs } from "dayjs"; import styled from "styled-components"; @@ -16,21 +16,22 @@ import { import { useRequiredStyle } from "@/hooks/useRequiredStyle"; import { useDatePickerLocale } from "../DateInput/hooks/useDatePickerLocale"; -const InputWrapper = styled.div` - display: flex; +const InputWrapper = styled.div<{ $hasSuffix?: boolean }>` + position: relative; + display: inline-flex; align-items: center; width: 100%; - gap: 4px; `; const StyledInput = styled(IMaskInput)<{ $hasError?: boolean; $required?: React.CSSProperties; + $hasSuffix?: boolean; }>` - flex: 1; width: 100%; height: 32px; padding: 4px 11px; + padding-right: ${(props) => (props.$hasSuffix ? "30px" : "11px")}; font-size: 14px; line-height: 1.5715; color: rgba(0, 0, 0, 0.88); @@ -59,8 +60,20 @@ const StyledInput = styled(IMaskInput)<{ } `; -const CalendarButton = styled(Button)` - flex-shrink: 0; +const SuffixIcon = styled.span` + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + color: rgba(0, 0, 0, 0.25); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: rgba(0, 0, 0, 0.45); + } `; const HiddenPicker = styled(DatePicker)` @@ -112,8 +125,17 @@ const MaskedDateInput: React.FC = memo((props) => { }, []); const commitValue = useCallback( - (maskedValue: string) => { + (maskedValue: string, shouldAutocompleteEmpty = false) => { if (!maskedValue || !hasAnyDigits(maskedValue)) { + if (shouldAutocompleteEmpty) { + const autocompleted = autocompleteDate(""); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + return; + } + } onChange?.(null); setInputValue(""); setParseError(null); @@ -153,12 +175,12 @@ const MaskedDateInput: React.FC = memo((props) => { if (e.key === "Enter") { e.preventDefault(); const input = e.target as HTMLInputElement; - commitValue(input.value); + commitValue(input.value, true); } else if (e.key === "Escape") { e.preventDefault(); const input = e.target as HTMLInputElement; - commitValue(input.value); + commitValue(input.value, true); setTimeout(() => { const focusableElements = @@ -179,7 +201,7 @@ const MaskedDateInput: React.FC = memo((props) => { const handleDoubleClick = useCallback( (e: React.MouseEvent) => { const input = e.target as HTMLInputElement; - commitValue(input.value); + commitValue(input.value, true); }, [commitValue], ); @@ -224,6 +246,8 @@ const MaskedDateInput: React.FC = memo((props) => { } }, [value, timezone]); + const showSuffix = showCalendarButton && !readOnly; + return ( = memo((props) => { color="#ff4d4f" placement="topLeft" > - + = memo((props) => { disabled={readOnly} $hasError={!!parseError} $required={requiredStyle} + $hasSuffix={showSuffix} /> - {showCalendarButton && !readOnly && ( + {showSuffix && ( <> - } - onClick={handleCalendarClick} - size="middle" - /> + + + ` + position: relative; + display: inline-flex; align-items: center; width: 100%; - gap: 4px; `; const StyledInput = styled(IMaskInput)<{ $hasError?: boolean; $required?: React.CSSProperties; + $hasSuffix?: boolean; }>` - flex: 1; width: 100%; height: 32px; padding: 4px 11px; + padding-right: ${(props) => (props.$hasSuffix ? "30px" : "11px")}; font-size: 14px; line-height: 1.5715; color: rgba(0, 0, 0, 0.88); @@ -59,8 +60,20 @@ const StyledInput = styled(IMaskInput)<{ } `; -const CalendarButton = styled(Button)` - flex-shrink: 0; +const SuffixIcon = styled.span` + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + color: rgba(0, 0, 0, 0.25); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: rgba(0, 0, 0, 0.45); + } `; const HiddenPicker = styled(DatePicker)` @@ -113,8 +126,17 @@ const MaskedDateTimeInput: React.FC = memo( }, []); const commitValue = useCallback( - (maskedValue: string) => { + (maskedValue: string, shouldAutocompleteEmpty = false) => { if (!maskedValue || !hasAnyDigits(maskedValue)) { + if (shouldAutocompleteEmpty) { + const autocompleted = autocompleteDateTime(""); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + return; + } + } onChange?.(null); setInputValue(""); setParseError(null); @@ -154,12 +176,12 @@ const MaskedDateTimeInput: React.FC = memo( if (e.key === "Enter") { e.preventDefault(); const input = e.target as HTMLInputElement; - commitValue(input.value); + commitValue(input.value, true); } else if (e.key === "Escape") { e.preventDefault(); const input = e.target as HTMLInputElement; - commitValue(input.value); + commitValue(input.value, true); setTimeout(() => { const focusableElements = @@ -180,7 +202,7 @@ const MaskedDateTimeInput: React.FC = memo( const handleDoubleClick = useCallback( (e: React.MouseEvent) => { const input = e.target as HTMLInputElement; - commitValue(input.value); + commitValue(input.value, true); }, [commitValue], ); @@ -227,6 +249,8 @@ const MaskedDateTimeInput: React.FC = memo( } }, [value, timezone]); + const showSuffix = showCalendarButton && !readOnly; + return ( = memo( color="#ff4d4f" placement="topLeft" > - + = memo( disabled={readOnly} $hasError={!!parseError} $required={requiredStyle} + $hasSuffix={showSuffix} /> - {showCalendarButton && !readOnly && ( + {showSuffix && ( <> - } - onClick={handleCalendarClick} - size="middle" - /> + + + ` + position: relative; + display: inline-flex; align-items: center; width: 100%; - gap: 4px; `; const StyledInput = styled(IMaskInput)<{ $hasError?: boolean; $required?: React.CSSProperties; + $hasSuffix?: boolean; }>` - flex: 1; width: 100%; height: 32px; padding: 4px 11px; + padding-right: ${(props) => (props.$hasSuffix ? "30px" : "11px")}; font-size: 14px; line-height: 1.5715; color: rgba(0, 0, 0, 0.88); @@ -56,8 +57,20 @@ const StyledInput = styled(IMaskInput)<{ } `; -const ClockButton = styled(Button)` - flex-shrink: 0; +const SuffixIcon = styled.span` + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + color: rgba(0, 0, 0, 0.25); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: rgba(0, 0, 0, 0.45); + } `; const HiddenPicker = styled(TimePicker)` @@ -110,8 +123,17 @@ const MaskedTimeInput: React.FC = memo((props) => { }, []); const commitValue = useCallback( - (maskedValue: string) => { + (maskedValue: string, shouldAutocompleteEmpty = false) => { if (!maskedValue || !hasAnyDigits(maskedValue)) { + if (shouldAutocompleteEmpty) { + const autocompleted = autocompleteTime("", dayjs(), useZeros); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + return; + } + } onChange?.(null); setInputValue(""); setParseError(null); @@ -142,12 +164,12 @@ const MaskedTimeInput: React.FC = memo((props) => { if (e.key === "Enter") { e.preventDefault(); const input = e.target as HTMLInputElement; - commitValue(input.value); + commitValue(input.value, true); } else if (e.key === "Escape") { e.preventDefault(); const input = e.target as HTMLInputElement; - commitValue(input.value); + commitValue(input.value, true); setTimeout(() => { const focusableElements = @@ -168,7 +190,7 @@ const MaskedTimeInput: React.FC = memo((props) => { const handleDoubleClick = useCallback( (e: React.MouseEvent) => { const input = e.target as HTMLInputElement; - commitValue(input.value); + commitValue(input.value, true); }, [commitValue], ); @@ -211,6 +233,8 @@ const MaskedTimeInput: React.FC = memo((props) => { } }, [value]); + const showSuffix = showClockButton && !readOnly; + return ( = memo((props) => { color="#ff4d4f" placement="topLeft" > - + = memo((props) => { disabled={readOnly} $hasError={!!parseError} $required={requiredStyle} + $hasSuffix={showSuffix} /> - {showClockButton && !readOnly && ( + {showSuffix && ( <> - } - onClick={handleClockClick} - size="middle" - /> + + + Date: Thu, 8 Jan 2026 14:16:24 +0100 Subject: [PATCH 05/18] feat: new approach for date with masked inputs https://github.com/gisce/webclient/issues/2291 --- package-lock.json | 66 +-- .../MaskedDateInput.stories.tsx | 21 +- .../Date/MaskedDateInput/MaskedDateInput.tsx | 424 ++++++++++-------- .../MaskedDateInput/MaskedDateInput.types.ts | 1 - .../MaskedDateTimeInput.stories.tsx | 23 +- .../MaskedDateTimeInput.tsx | 176 +++++--- .../MaskedDateTimeInput.types.ts | 1 - .../MaskedTimeInput.stories.tsx | 22 +- .../Date/MaskedTimeInput/MaskedTimeInput.tsx | 418 ++++++++++------- .../MaskedTimeInput/MaskedTimeInput.types.ts | 1 - 10 files changed, 664 insertions(+), 489 deletions(-) diff --git a/package-lock.json b/package-lock.json index 379be882..3337dc65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -288,6 +288,7 @@ "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", @@ -3654,6 +3655,7 @@ "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", @@ -4697,8 +4699,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.9.1", @@ -4712,8 +4713,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.9.1", @@ -4726,8 +4726,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.9.1", @@ -4741,8 +4740,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.9.1", @@ -4756,8 +4754,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.9.1", @@ -4771,8 +4768,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.9.1", @@ -4786,8 +4782,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.9.1", @@ -4801,8 +4796,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.9.1", @@ -4816,8 +4810,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.9.1", @@ -4831,8 +4824,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.9.1", @@ -4846,8 +4838,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.9.1", @@ -4861,8 +4852,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.9.1", @@ -4876,8 +4866,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rushstack/node-core-library": { "version": "3.62.0", @@ -7274,6 +7263,7 @@ "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", @@ -7634,6 +7624,7 @@ "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" } @@ -7694,6 +7685,7 @@ "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": "*" } @@ -7821,6 +7813,7 @@ "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", @@ -7855,6 +7848,7 @@ "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", @@ -8289,6 +8283,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9116,6 +9111,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -10225,6 +10221,7 @@ "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", @@ -10347,6 +10344,7 @@ "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" }, @@ -10370,7 +10368,8 @@ "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "peer": true }, "node_modules/de-indent": { "version": "1.0.2", @@ -11161,6 +11160,7 @@ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11261,6 +11261,7 @@ "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", @@ -11460,6 +11461,7 @@ "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", @@ -11522,6 +11524,7 @@ "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", @@ -11547,6 +11550,7 @@ "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" }, @@ -12996,6 +13000,7 @@ "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", @@ -15289,6 +15294,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz", "integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==", "dev": true, + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -20832,6 +20838,7 @@ "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" }, @@ -20919,6 +20926,7 @@ "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" @@ -21767,6 +21775,7 @@ "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", @@ -22851,6 +22860,7 @@ "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", @@ -23527,6 +23537,7 @@ "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" @@ -23912,6 +23923,7 @@ "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/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx index f00f0d09..1f53a2f7 100644 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx @@ -70,11 +70,6 @@ A date input with DD/MM/YYYY mask that supports **partial entry with autocomplet description: "Whether the field is read-only", defaultValue: false, }, - showCalendarButton: { - control: "boolean", - description: "Whether to show the calendar button", - defaultValue: true, - }, }, } as Meta; @@ -116,7 +111,6 @@ const Template: StoryFn = (args) => { required={args.required} readOnly={args.readOnly} timezone={args.timezone} - showCalendarButton={args.showCalendarButton} />
= (args) => { > Debug Information:
-            Internal value: {currentValue || "(empty)"}
-            timezone: {args.timezone}
-            required: {args.required?.toString()}
-            readOnly: {args.readOnly?.toString()}
+            {`Internal value: ${currentValue || "(empty)"}
+timezone: ${args.timezone}
+required: ${args.required?.toString()}
+readOnly: ${args.readOnly?.toString()}`}
           
@@ -259,7 +253,6 @@ Basic.args = { required: false, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: true, }; // Empty state showing the mask placeholder @@ -270,7 +263,6 @@ EmptyWithMask.args = { required: false, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: true, }; EmptyWithMask.parameters = { docs: { @@ -289,7 +281,6 @@ Required.args = { required: true, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: true, }; Required.parameters = { docs: { @@ -308,7 +299,6 @@ ReadOnly.args = { required: false, readOnly: true, timezone: "Europe/Madrid", - showCalendarButton: true, }; ReadOnly.parameters = { docs: { @@ -326,7 +316,6 @@ WithoutCalendarButton.args = { required: false, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: false, }; WithoutCalendarButton.parameters = { docs: { @@ -345,7 +334,6 @@ TimezoneUTC.args = { required: false, readOnly: false, timezone: "UTC", - showCalendarButton: true, }; // Different timezone - Tokyo @@ -356,7 +344,6 @@ TimezoneTokyo.args = { required: false, readOnly: false, timezone: "Asia/Tokyo", - showCalendarButton: true, }; // Comprehensive keyboard behavior demo diff --git a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx index 80acbe50..768244c3 100644 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx @@ -1,8 +1,8 @@ import { memo, useCallback, useMemo, useRef, useState } from "react"; import { IMaskInput } from "react-imask"; -import { Button, DatePicker, Tooltip } from "antd"; -import { CalendarOutlined } from "@ant-design/icons"; -import dayjs, { Dayjs } from "dayjs"; +import { DatePicker, Tooltip } from "antd"; +import { CalendarOutlined, CloseCircleFilled } from "@ant-design/icons"; +import dayjs from "dayjs"; import styled from "styled-components"; import { MaskedDateInputProps } from "./MaskedDateInput.types"; import { @@ -21,11 +21,13 @@ const InputWrapper = styled.div` align-items: center; width: 100%; gap: 4px; + position: relative; `; const StyledInput = styled(IMaskInput)<{ $hasError?: boolean; $required?: React.CSSProperties; + $isEmpty?: boolean; }>` flex: 1; width: 100%; @@ -33,7 +35,8 @@ const StyledInput = styled(IMaskInput)<{ padding: 4px 11px; font-size: 14px; line-height: 1.5715; - color: rgba(0, 0, 0, 0.88); + color: ${(props) => + props.$isEmpty ? "rgba(0, 0, 0, 0.25)" : "rgba(0, 0, 0, 0.88)"}; background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; border-radius: 6px; @@ -59,202 +62,267 @@ const StyledInput = styled(IMaskInput)<{ } `; -const CalendarButton = styled(Button)` - flex-shrink: 0; +const SuffixIcon = styled.span<{ $allowClear?: boolean }>` + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + color: rgba(0, 0, 0, 0.25); + cursor: ${(props) => (props.$allowClear ? "pointer" : "default")}; + transition: color 0.2s; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${(props) => + props.$allowClear ? "rgba(0, 0, 0, 0.45)" : "rgba(0, 0, 0, 0.25)"}; + } `; -const HiddenPicker = styled(DatePicker)` +const InputContainer = styled.div` + position: relative; + flex: 1; + display: flex; + align-items: center; +`; + +const HiddenPickerTrigger = styled(DatePicker)` position: absolute; + left: 0; + top: 100%; opacity: 0; pointer-events: none; - width: 0; - height: 0; + width: 1px; + height: 1px; `; -const MaskedDateInput: React.FC = memo((props) => { - const { - value, - onChange, - id, - readOnly = false, - required = false, - timezone = "Europe/Madrid", - showCalendarButton = true, - placeholder = MaskedDateConfig.placeholder, - } = props; - - const inputRef = useRef(null); - const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( - null, - ); - const [calendarOpen, setCalendarOpen] = useState(false); - const [parseError, setParseError] = useState(null); - const [inputValue, setInputValue] = useState(""); - - const datePickerLocale = useDatePickerLocale(); - const requiredStyle = useRequiredStyle(required, readOnly); - - const displayValue = useMemo(() => { - if (!value) return ""; - return parseInternalToDisplay( +const MaskedDateInput: React.FC = memo( + (props: MaskedDateInputProps) => { + const { value, - MaskedDateConfig.internalFormat, - MaskedDateConfig.displayFormat, - timezone, + onChange, + id, + readOnly = false, + required = false, + timezone = "Europe/Madrid", + placeholder = MaskedDateConfig.placeholder, + } = props; + + const inputRef = useRef(null); + const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( + null, ); - }, [value, timezone]); + const [calendarOpen, setCalendarOpen] = useState(false); + const [parseError, setParseError] = useState(null); + const [inputValue, setInputValue] = useState(""); + const [isHovered, setIsHovered] = useState(false); - const currentInputValue = inputValue || displayValue; + const datePickerLocale = useDatePickerLocale(); + const requiredStyle = useRequiredStyle(required, readOnly); - const handleAccept = useCallback((maskedValue: string) => { - setInputValue(maskedValue); - setParseError(null); - }, []); + const displayValue = useMemo(() => { + if (!value) return ""; + return parseInternalToDisplay( + value, + MaskedDateConfig.internalFormat, + MaskedDateConfig.displayFormat, + timezone, + ); + }, [value, timezone]); - const commitValue = useCallback( - (maskedValue: string) => { - if (!maskedValue || !hasAnyDigits(maskedValue)) { - onChange?.(null); - setInputValue(""); - setParseError(null); - return; - } + const currentInputValue = inputValue || displayValue; - if (isCompleteValue(maskedValue, placeholder)) { - const internalValue = parseDisplayToInternal( - maskedValue, - MaskedDateConfig.displayFormat, - MaskedDateConfig.internalFormat, - ); - if (internalValue) { - onChange?.(internalValue); + const handleAccept = useCallback((maskedValue: string) => { + setInputValue(maskedValue); + setParseError(null); + }, []); + + const commitValue = useCallback( + (maskedValue: string) => { + if (!maskedValue || !hasAnyDigits(maskedValue)) { + onChange?.(null); + setInputValue(""); + setParseError(null); + return; + } + + if (isCompleteValue(maskedValue, placeholder)) { + const internalValue = parseDisplayToInternal( + maskedValue, + MaskedDateConfig.displayFormat, + MaskedDateConfig.internalFormat, + ); + if (internalValue) { + onChange?.(internalValue); + setInputValue(""); + setParseError(null); + } else { + setParseError("Invalid date"); + } + return; + } + + const autocompleted = autocompleteDate(maskedValue); + if (autocompleted) { + onChange?.(autocompleted.internalValue); setInputValue(""); setParseError(null); } else { - setParseError("Invalid date"); + setParseError("Invalid date format"); } - return; - } + }, + [onChange, placeholder], + ); - const autocompleted = autocompleteDate(maskedValue); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(""); - setParseError(null); - } else { - setParseError("Invalid date format"); - } - }, - [onChange, placeholder], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + const maskedValue = input.value; + + // On Enter, if no digits entered, autocomplete to current date + if (!hasAnyDigits(maskedValue)) { + const autocompleted = autocompleteDate(""); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + } + return; + } + + commitValue(maskedValue); + } else if (e.key === "Escape") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + + 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); + } + }, + [commitValue, onChange], + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { const input = e.target as HTMLInputElement; commitValue(input.value); - } else if (e.key === "Escape") { - e.preventDefault(); - const input = e.target as HTMLInputElement; + }, + [commitValue], + ); - commitValue(input.value); + const handleBlur = useCallback( + (e: React.FocusEvent) => { + // Close calendar and commit value + setCalendarOpen(false); + commitValue(e.target.value); + }, + [commitValue], + ); + const handleCalendarChange = useCallback( + (date: unknown, dateString: string | string[]) => { + setCalendarOpen(false); + if (date && dayjs.isDayjs(date)) { + const internalValue = date.format(MaskedDateConfig.internalFormat); + onChange?.(internalValue); + setInputValue(""); + setParseError(null); + } 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(); - } + inputRef.current?.focus(); }, 50); + }, + [onChange], + ); + + const handleFocus = useCallback(() => { + if (!readOnly) { + setCalendarOpen(true); } - }, - [commitValue], - ); - - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - const input = e.target as HTMLInputElement; - commitValue(input.value); - }, - [commitValue], - ); - - const handleBlur = useCallback( - (e: React.FocusEvent) => { - if (calendarOpen) return; - commitValue(e.target.value); - }, - [commitValue, calendarOpen], - ); - - const handleCalendarChange = useCallback( - (date: unknown, dateString: string | string[]) => { - setCalendarOpen(false); - if (date && dayjs.isDayjs(date)) { - const internalValue = date.format(MaskedDateConfig.internalFormat); - onChange?.(internalValue); + }, [readOnly]); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange?.(null); setInputValue(""); setParseError(null); - } - setTimeout(() => { inputRef.current?.focus(); - }, 50); - }, - [onChange], - ); - - const handleCalendarClick = useCallback(() => { - setCalendarOpen(true); - }, []); - - const pickerValue = useMemo(() => { - if (!value) return undefined; - try { - const parsed = timezone - ? dayjs.tz(value, MaskedDateConfig.internalFormat, timezone) - : dayjs(value, MaskedDateConfig.internalFormat); - return parsed.isValid() ? parsed : undefined; - } catch { - return undefined; - } - }, [value, timezone]); - - return ( - - - - {showCalendarButton && !readOnly && ( - <> - } - onClick={handleCalendarClick} - size="middle" + }, + [onChange], + ); + + const pickerValue = useMemo(() => { + if (!value) return undefined; + try { + const parsed = timezone + ? dayjs.tz(value, MaskedDateConfig.internalFormat, timezone) + : dayjs(value, MaskedDateConfig.internalFormat); + return parsed.isValid() ? parsed : undefined; + } catch { + return undefined; + } + }, [value, timezone]); + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + - + {value && isHovered ? ( + + ) : ( + + )} + + )} + = memo((props) => { showNow={false} showToday={false} locale={datePickerLocale} + placement="bottomLeft" + tabIndex={-1} /> - - )} - - - ); -}); + + + + ); + }, +); MaskedDateInput.displayName = "MaskedDateInput"; diff --git a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts index a905f2bb..5dbf789a 100644 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts +++ b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts @@ -5,6 +5,5 @@ export type MaskedDateInputProps = { readOnly?: boolean; required?: boolean; timezone?: string; - showCalendarButton?: boolean; placeholder?: string; }; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx index f9a2a777..cf2c88c1 100644 --- a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx +++ b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx @@ -66,11 +66,6 @@ and the seconds will autocomplete from the current time. description: "Whether the field is read-only", defaultValue: false, }, - showCalendarButton: { - control: "boolean", - description: "Whether to show the calendar button", - defaultValue: true, - }, }, } as Meta; @@ -112,7 +107,6 @@ const Template: StoryFn = (args) => { required={args.required} readOnly={args.readOnly} timezone={args.timezone} - showCalendarButton={args.showCalendarButton} />
= (args) => { > Debug Information:
-            Internal value: {currentValue || "(empty)"}
-            timezone: {args.timezone}
-            required: {args.required?.toString()}
-            readOnly: {args.readOnly?.toString()}
+            {`Internal value: ${currentValue || "(empty)"}
+timezone: ${args.timezone}
+required: ${args.required?.toString()}
+readOnly: ${args.readOnly?.toString()}`}
           
@@ -280,7 +274,6 @@ Basic.args = { required: false, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: true, }; // Empty state showing the mask placeholder @@ -291,7 +284,6 @@ EmptyWithMask.args = { required: false, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: true, }; EmptyWithMask.parameters = { docs: { @@ -309,7 +301,6 @@ Required.args = { required: true, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: true, }; // Read-only field @@ -320,7 +311,6 @@ ReadOnly.args = { required: false, readOnly: true, timezone: "Europe/Madrid", - showCalendarButton: true, }; // Without calendar button @@ -331,7 +321,6 @@ WithoutCalendarButton.args = { required: false, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: false, }; // Different timezone - UTC @@ -342,7 +331,6 @@ TimezoneUTC.args = { required: false, readOnly: false, timezone: "UTC", - showCalendarButton: true, }; // Different timezone - Tokyo @@ -353,7 +341,6 @@ TimezoneTokyo.args = { required: false, readOnly: false, timezone: "Asia/Tokyo", - showCalendarButton: true, }; // Midnight time @@ -364,7 +351,6 @@ Midnight.args = { required: false, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: true, }; // End of day @@ -375,5 +361,4 @@ EndOfDay.args = { required: false, readOnly: false, timezone: "Europe/Madrid", - showCalendarButton: true, }; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx index 81b94699..0f83edc6 100644 --- a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx +++ b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx @@ -1,8 +1,8 @@ import { memo, useCallback, useMemo, useRef, useState } from "react"; import { IMaskInput } from "react-imask"; -import { Button, DatePicker, Tooltip } from "antd"; -import { CalendarOutlined } from "@ant-design/icons"; -import dayjs, { Dayjs } from "dayjs"; +import { DatePicker, Tooltip } from "antd"; +import { CalendarOutlined, CloseCircleFilled } from "@ant-design/icons"; +import dayjs from "dayjs"; import styled from "styled-components"; import { MaskedDateTimeInputProps } from "./MaskedDateTimeInput.types"; import { @@ -21,11 +21,13 @@ const InputWrapper = styled.div` align-items: center; width: 100%; gap: 4px; + position: relative; `; const StyledInput = styled(IMaskInput)<{ $hasError?: boolean; $required?: React.CSSProperties; + $isEmpty?: boolean; }>` flex: 1; width: 100%; @@ -33,7 +35,8 @@ const StyledInput = styled(IMaskInput)<{ padding: 4px 11px; font-size: 14px; line-height: 1.5715; - color: rgba(0, 0, 0, 0.88); + color: ${(props) => + props.$isEmpty ? "rgba(0, 0, 0, 0.25)" : "rgba(0, 0, 0, 0.88)"}; background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; border-radius: 6px; @@ -59,20 +62,44 @@ const StyledInput = styled(IMaskInput)<{ } `; -const CalendarButton = styled(Button)` - flex-shrink: 0; +const SuffixIcon = styled.span<{ $allowClear?: boolean }>` + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + color: rgba(0, 0, 0, 0.25); + cursor: ${(props) => (props.$allowClear ? "pointer" : "default")}; + transition: color 0.2s; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${(props) => + props.$allowClear ? "rgba(0, 0, 0, 0.45)" : "rgba(0, 0, 0, 0.25)"}; + } `; -const HiddenPicker = styled(DatePicker)` +const InputContainer = styled.div` + position: relative; + flex: 1; + display: flex; + align-items: center; +`; + +const HiddenPickerTrigger = styled(DatePicker)` position: absolute; + left: 0; + top: 100%; opacity: 0; pointer-events: none; - width: 0; - height: 0; + width: 1px; + height: 1px; `; const MaskedDateTimeInput: React.FC = memo( - (props) => { + (props: MaskedDateTimeInputProps) => { const { value, onChange, @@ -80,7 +107,6 @@ const MaskedDateTimeInput: React.FC = memo( readOnly = false, required = false, timezone = "Europe/Madrid", - showCalendarButton = true, placeholder = MaskedDateTimeConfig.placeholder, } = props; @@ -91,6 +117,7 @@ const MaskedDateTimeInput: React.FC = memo( const [calendarOpen, setCalendarOpen] = useState(false); const [parseError, setParseError] = useState(null); const [inputValue, setInputValue] = useState(""); + const [isHovered, setIsHovered] = useState(false); const datePickerLocale = useDatePickerLocale(); const requiredStyle = useRequiredStyle(required, readOnly); @@ -154,7 +181,20 @@ const MaskedDateTimeInput: React.FC = memo( if (e.key === "Enter") { e.preventDefault(); const input = e.target as HTMLInputElement; - commitValue(input.value); + const maskedValue = input.value; + + // On Enter, if no digits entered, autocomplete to current datetime + if (!hasAnyDigits(maskedValue)) { + const autocompleted = autocompleteDateTime(""); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + } + return; + } + + commitValue(maskedValue); } else if (e.key === "Escape") { e.preventDefault(); const input = e.target as HTMLInputElement; @@ -174,7 +214,7 @@ const MaskedDateTimeInput: React.FC = memo( }, 50); } }, - [commitValue], + [commitValue, onChange], ); const handleDoubleClick = useCallback( @@ -187,10 +227,11 @@ const MaskedDateTimeInput: React.FC = memo( const handleBlur = useCallback( (e: React.FocusEvent) => { - if (calendarOpen) return; + // Close calendar and commit value + setCalendarOpen(false); commitValue(e.target.value); }, - [commitValue, calendarOpen], + [commitValue], ); const handleCalendarChange = useCallback( @@ -211,9 +252,22 @@ const MaskedDateTimeInput: React.FC = memo( [onChange], ); - const handleCalendarClick = useCallback(() => { - setCalendarOpen(true); - }, []); + const handleFocus = useCallback(() => { + if (!readOnly) { + setCalendarOpen(true); + } + }, [readOnly]); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange?.(null); + setInputValue(""); + setParseError(null); + inputRef.current?.focus(); + }, + [onChange], + ); const pickerValue = useMemo(() => { if (!value) return undefined; @@ -234,42 +288,56 @@ const MaskedDateTimeInput: React.FC = memo( color="#ff4d4f" placement="topLeft" > - - - {showCalendarButton && !readOnly && ( - <> - } - onClick={handleCalendarClick} - size="middle" - /> - - - )} + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + {!readOnly && ( + + {value && isHovered ? ( + + ) : ( + + )} + + )} + + ); diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts index cba68f26..3220585a 100644 --- a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts +++ b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts @@ -5,6 +5,5 @@ export type MaskedDateTimeInputProps = { readOnly?: boolean; required?: boolean; timezone?: string; - showCalendarButton?: boolean; placeholder?: string; }; diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx index 09e7e713..96f8695c 100644 --- a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx +++ b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx @@ -50,11 +50,6 @@ For example, to change only the minutes, they type hour:minutes and seconds auto description: "Whether the field is read-only", defaultValue: false, }, - showClockButton: { - control: "boolean", - description: "Whether to show the clock button", - defaultValue: true, - }, useZeros: { control: "boolean", description: @@ -101,7 +96,6 @@ const Template: StoryFn = (args) => { onChange={handleChange} required={args.required} readOnly={args.readOnly} - showClockButton={args.showClockButton} useZeros={args.useZeros} />
@@ -115,10 +109,10 @@ const Template: StoryFn = (args) => { > Debug Information:
-            Internal value: {currentValue || "(empty)"}
-            required: {args.required?.toString()}
-            readOnly: {args.readOnly?.toString()}
-            useZeros: {args.useZeros?.toString()}
+            {`Internal value: ${currentValue || "(empty)"}
+required: ${args.required?.toString()}
+readOnly: ${args.readOnly?.toString()}
+useZeros: ${args.useZeros?.toString()}`}
           
@@ -263,7 +257,6 @@ Basic.args = { value: "14:30:00", required: false, readOnly: false, - showClockButton: true, useZeros: false, }; @@ -274,7 +267,6 @@ EmptyWithMask.args = { value: undefined, required: false, readOnly: false, - showClockButton: true, useZeros: false, }; EmptyWithMask.parameters = { @@ -292,7 +284,6 @@ Required.args = { value: "14:30:00", required: true, readOnly: false, - showClockButton: true, useZeros: false, }; @@ -303,7 +294,6 @@ ReadOnly.args = { value: "14:30:00", required: false, readOnly: true, - showClockButton: true, useZeros: false, }; @@ -314,7 +304,6 @@ WithoutClockButton.args = { value: "14:30:00", required: false, readOnly: false, - showClockButton: false, useZeros: false, }; @@ -325,7 +314,6 @@ WithUseZeros.args = { value: undefined, required: false, readOnly: false, - showClockButton: true, useZeros: true, }; WithUseZeros.parameters = { @@ -344,7 +332,6 @@ Midnight.args = { value: "00:00:00", required: false, readOnly: false, - showClockButton: true, useZeros: false, }; @@ -355,6 +342,5 @@ EndOfDay.args = { value: "23:59:59", required: false, readOnly: false, - showClockButton: true, useZeros: false, }; diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx index 846c8b52..909c1e66 100644 --- a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx +++ b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useMemo, useRef, useState } from "react"; import { IMaskInput } from "react-imask"; -import { Button, TimePicker, Tooltip } from "antd"; -import { ClockCircleOutlined } from "@ant-design/icons"; +import { TimePicker, Tooltip } from "antd"; +import { ClockCircleOutlined, CloseCircleFilled } from "@ant-design/icons"; import dayjs, { Dayjs } from "dayjs"; import styled from "styled-components"; import { MaskedTimeInputProps } from "./MaskedTimeInput.types"; @@ -18,11 +18,13 @@ const InputWrapper = styled.div` align-items: center; width: 100%; gap: 4px; + position: relative; `; const StyledInput = styled(IMaskInput)<{ $hasError?: boolean; $required?: React.CSSProperties; + $isEmpty?: boolean; }>` flex: 1; width: 100%; @@ -30,7 +32,8 @@ const StyledInput = styled(IMaskInput)<{ padding: 4px 11px; font-size: 14px; line-height: 1.5715; - color: rgba(0, 0, 0, 0.88); + color: ${(props) => + props.$isEmpty ? "rgba(0, 0, 0, 0.25)" : "rgba(0, 0, 0, 0.88)"}; background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; border-radius: 6px; @@ -56,192 +59,257 @@ const StyledInput = styled(IMaskInput)<{ } `; -const ClockButton = styled(Button)` - flex-shrink: 0; +const SuffixIcon = styled.span<{ $allowClear?: boolean }>` + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + color: rgba(0, 0, 0, 0.25); + cursor: ${(props) => (props.$allowClear ? "pointer" : "default")}; + transition: color 0.2s; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${(props) => + props.$allowClear ? "rgba(0, 0, 0, 0.45)" : "rgba(0, 0, 0, 0.25)"}; + } +`; + +const InputContainer = styled.div` + position: relative; + flex: 1; + display: flex; + align-items: center; `; -const HiddenPicker = styled(TimePicker)` +const HiddenPickerTrigger = styled(TimePicker)` position: absolute; + left: 0; + top: 100%; opacity: 0; pointer-events: none; - width: 0; - height: 0; + width: 1px; + height: 1px; `; -const MaskedTimeInput: React.FC = memo((props) => { - const { - value, - onChange, - id, - readOnly = false, - required = false, - showClockButton = true, - placeholder = MaskedTimeConfig.placeholder, - useZeros = false, - } = props; - - const inputRef = useRef(null); - const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( - null, - ); - const [pickerOpen, setPickerOpen] = useState(false); - const [parseError, setParseError] = useState(null); - const [inputValue, setInputValue] = useState(""); - - const requiredStyle = useRequiredStyle(required, readOnly); - - const displayValue = useMemo(() => { - if (!value) return ""; - try { - const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); - return parsed.isValid() - ? parsed.format(MaskedTimeConfig.displayFormat) - : ""; - } catch { - return ""; - } - }, [value]); - - const currentInputValue = inputValue || displayValue; - - const handleAccept = useCallback((maskedValue: string) => { - setInputValue(maskedValue); - setParseError(null); - }, []); - - const commitValue = useCallback( - (maskedValue: string) => { - if (!maskedValue || !hasAnyDigits(maskedValue)) { - onChange?.(null); - setInputValue(""); - setParseError(null); - return; - } +const MaskedTimeInput: React.FC = memo( + (props: MaskedTimeInputProps) => { + const { + value, + onChange, + id, + readOnly = false, + required = false, + placeholder = MaskedTimeConfig.placeholder, + useZeros = false, + } = props; - if (isCompleteValue(maskedValue, placeholder)) { - onChange?.(maskedValue); - setInputValue(""); - setParseError(null); - return; - } + const inputRef = useRef(null); + const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( + null, + ); + const [pickerOpen, setPickerOpen] = useState(false); + const [parseError, setParseError] = useState(null); + const [inputValue, setInputValue] = useState(""); + const [isHovered, setIsHovered] = useState(false); - const autocompleted = autocompleteTime(maskedValue, dayjs(), useZeros); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(""); - setParseError(null); - } else { - setParseError("Invalid time format"); + const requiredStyle = useRequiredStyle(required, readOnly); + + const displayValue = useMemo(() => { + if (!value) return ""; + try { + const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); + return parsed.isValid() + ? parsed.format(MaskedTimeConfig.displayFormat) + : ""; + } catch { + return ""; } - }, - [onChange, placeholder, useZeros], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); + }, [value]); + + const currentInputValue = inputValue || displayValue; + + const handleAccept = useCallback((maskedValue: string) => { + setInputValue(maskedValue); + setParseError(null); + }, []); + + const commitValue = useCallback( + (maskedValue: string) => { + if (!maskedValue || !hasAnyDigits(maskedValue)) { + onChange?.(null); + setInputValue(""); + setParseError(null); + return; + } + + if (isCompleteValue(maskedValue, placeholder)) { + onChange?.(maskedValue); + setInputValue(""); + setParseError(null); + return; + } + + const autocompleted = autocompleteTime(maskedValue, dayjs(), useZeros); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + } else { + setParseError("Invalid time format"); + } + }, + [onChange, placeholder, useZeros], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + const maskedValue = input.value; + + // On Enter, if no digits entered, autocomplete to current time + if (!hasAnyDigits(maskedValue)) { + const autocompleted = autocompleteTime("", dayjs(), useZeros); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + } + return; + } + + commitValue(maskedValue); + } else if (e.key === "Escape") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + + 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); + } + }, + [commitValue, onChange, useZeros], + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { const input = e.target as HTMLInputElement; commitValue(input.value); - } else if (e.key === "Escape") { - e.preventDefault(); - const input = e.target as HTMLInputElement; + }, + [commitValue], + ); - commitValue(input.value); + const handleBlur = useCallback( + (e: React.FocusEvent) => { + // Close picker and commit value + setPickerOpen(false); + commitValue(e.target.value); + }, + [commitValue], + ); + const handlePickerChange = useCallback( + (time: Dayjs | null) => { + setPickerOpen(false); + if (time) { + const timeValue = time.format(MaskedTimeConfig.internalFormat); + onChange?.(timeValue); + setInputValue(""); + setParseError(null); + } 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(); - } + inputRef.current?.focus(); }, 50); + }, + [onChange], + ); + + const handleFocus = useCallback(() => { + if (!readOnly) { + setPickerOpen(true); } - }, - [commitValue], - ); - - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - const input = e.target as HTMLInputElement; - commitValue(input.value); - }, - [commitValue], - ); - - const handleBlur = useCallback( - (e: React.FocusEvent) => { - if (pickerOpen) return; - commitValue(e.target.value); - }, - [commitValue, pickerOpen], - ); - - const handlePickerChange = useCallback( - (time: Dayjs | null) => { - setPickerOpen(false); - if (time) { - const timeValue = time.format(MaskedTimeConfig.internalFormat); - onChange?.(timeValue); + }, [readOnly]); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onChange?.(null); setInputValue(""); setParseError(null); - } - setTimeout(() => { inputRef.current?.focus(); - }, 50); - }, - [onChange], - ); - - const handleClockClick = useCallback(() => { - setPickerOpen(true); - }, []); - - const pickerValue = useMemo(() => { - if (!value) return undefined; - try { - const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); - return parsed.isValid() ? parsed : undefined; - } catch { - return undefined; - } - }, [value]); - - return ( - - - - {showClockButton && !readOnly && ( - <> - } - onClick={handleClockClick} - size="middle" + }, + [onChange], + ); + + const pickerValue = useMemo(() => { + if (!value) return undefined; + try { + const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); + return parsed.isValid() ? parsed : undefined; + } catch { + return undefined; + } + }, [value]); + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + - + {value && isHovered ? ( + + ) : ( + + )} + + )} + = memo((props) => { onChange={handlePickerChange} showNow={false} format={MaskedTimeConfig.displayFormat} + placement="bottomLeft" + tabIndex={-1} /> - - )} - - - ); -}); + + + + ); + }, +); MaskedTimeInput.displayName = "MaskedTimeInput"; diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts index 77dac68e..25ad04c2 100644 --- a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts +++ b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts @@ -4,7 +4,6 @@ export type MaskedTimeInputProps = { id?: string; readOnly?: boolean; required?: boolean; - showClockButton?: boolean; placeholder?: string; useZeros?: boolean; }; From abde82811d4fd31ba9cc2c12b612bff77672ac0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 8 Jan 2026 20:41:14 +0100 Subject: [PATCH 06/18] feat: tests and date masked input --- .gitignore | 3 + package-lock.json | 64 ++ package.json | 10 +- playwright-tests/DateInput.spec.ts | 629 +++++++++++++++++ playwright-tests/DateMaskedInput.spec.ts | 631 +++++++++++++++++ playwright.config.ts | 33 + .../DateInput/hooks/useDatePickerHandlers.ts | 39 ++ .../DateMaskedInput.comparison.stories.tsx | 339 ++++++++++ .../DateMaskedInput.stories.tsx | 547 +++++++++++++++ .../Date/DateMaskedInput/DateMaskedInput.tsx | 633 ++++++++++++++++++ .../DateMaskedInput/DateMaskedInput.types.ts | 22 + .../MaskedDate.helpers.ts | 0 .../widgets/Date/DateMaskedInput/index.ts | 5 + .../MaskedDateInput.stories.tsx | 565 ---------------- .../Date/MaskedDateInput/MaskedDateInput.tsx | 346 ---------- .../MaskedDateInput/MaskedDateInput.types.ts | 9 - .../widgets/Date/MaskedDateInput/index.ts | 3 - .../MaskedDateTimeInput.stories.tsx | 364 ---------- .../MaskedDateTimeInput.tsx | 349 ---------- .../MaskedDateTimeInput.types.ts | 9 - .../widgets/Date/MaskedDateTimeInput/index.ts | 2 - .../MaskedTimeInput.stories.tsx | 346 ---------- .../Date/MaskedTimeInput/MaskedTimeInput.tsx | 332 --------- .../MaskedTimeInput/MaskedTimeInput.types.ts | 9 - .../widgets/Date/MaskedTimeInput/index.ts | 2 - src/components/widgets/Date/index.ts | 4 +- 26 files changed, 2953 insertions(+), 2342 deletions(-) create mode 100644 playwright-tests/DateInput.spec.ts create mode 100644 playwright-tests/DateMaskedInput.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/components/widgets/Date/DateMaskedInput/DateMaskedInput.comparison.stories.tsx create mode 100644 src/components/widgets/Date/DateMaskedInput/DateMaskedInput.stories.tsx create mode 100644 src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx create mode 100644 src/components/widgets/Date/DateMaskedInput/DateMaskedInput.types.ts rename src/components/widgets/Date/{MaskedDateInput/helpers => DateMaskedInput}/MaskedDate.helpers.ts (100%) create mode 100644 src/components/widgets/Date/DateMaskedInput/index.ts delete mode 100644 src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx delete mode 100644 src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx delete mode 100644 src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts delete mode 100644 src/components/widgets/Date/MaskedDateInput/index.ts delete mode 100644 src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx delete mode 100644 src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx delete mode 100644 src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts delete mode 100644 src/components/widgets/Date/MaskedDateTimeInput/index.ts delete mode 100644 src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx delete mode 100644 src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx delete mode 100644 src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts delete mode 100644 src/components/widgets/Date/MaskedTimeInput/index.ts 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 3337dc65..5ccadab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,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", @@ -3798,6 +3799,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", @@ -19857,6 +19874,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", diff --git a/package.json b/package.json index 4af9ef29..8bdce35a 100644 --- a/package.json +++ b/package.json @@ -39,20 +39,23 @@ "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", - "imask": "^7.6.0", - "react-imask": "^7.6.0", "@tabler/icons-react": "^3.31.0", "antd": "5.25.1", "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" @@ -68,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..532236fd --- /dev/null +++ b/playwright-tests/DateInput.spec.ts @@ -0,0 +1,629 @@ +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 components to fully render + await page.waitForTimeout(500); +} + +// 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(); + await page.waitForTimeout(300); + + // Verify the input is now empty + const inputValue = await input.inputValue(); + expect(inputValue).toBe(""); + + // 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(); + 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-dateinput--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-dateinput--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-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 }) => { + await goToStory(page, "components-widgets-date-dateinput--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-inner").filter({ hasText: /^15$/ }).first(); + 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-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(); + 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-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("closes picker and moves focus on Tab key", async ({ page }) => { + await goToStory(page, "components-widgets-date-dateinput--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-dateinput--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-inner").filter({ hasText: /^15$/ }).first(); + 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-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"); + }); + }); +}); diff --git a/playwright-tests/DateMaskedInput.spec.ts b/playwright-tests/DateMaskedInput.spec.ts new file mode 100644 index 00000000..4ef5b453 --- /dev/null +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -0,0 +1,631 @@ +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 components to fully render + await page.waitForTimeout(500); +} + +// 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(); + await page.waitForTimeout(300); + + // Verify the input is now empty + const inputValue = await input.inputValue(); + expect(inputValue).toBe(""); + + // 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-inner").filter({ hasText: /^15$/ }).first(); + 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-inner").filter({ hasText: /^15$/ }).first(); + 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"); + }); + }); +}); 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/hooks/useDatePickerHandlers.ts b/src/components/widgets/Date/DateInput/hooks/useDatePickerHandlers.ts index f36b2656..af4486a0 100644 --- a/src/components/widgets/Date/DateInput/hooks/useDatePickerHandlers.ts +++ b/src/components/widgets/Date/DateInput/hooks/useDatePickerHandlers.ts @@ -91,6 +91,45 @@ export const useDatePickerHandlers = ({ elements[index + 1].focus(); } }, 100); + } else if (e.key === "Tab") { + // Close picker before Tab navigates away + e.preventDefault(); + + const input = e.currentTarget; + if (input.value !== "") { + const dayJsDate = dayjs( + input.value, + DatePickerConfig[mode].dateDisplayFormat, + ).format(DatePickerConfig[mode].dateInternalFormat); + onChange?.(dayJsDate); + } + + // Blur input to close the picker + input.blur(); + + // Manually move focus to next/previous element based on Shift key + setTimeout(() => { + const focusableElements = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const elements = Array.from( + document.querySelectorAll(focusableElements), + ).filter((el) => { + // Exclude elements inside picker dropdown + return !el.closest(".ant-picker-dropdown"); + }) as HTMLElement[]; + const index = elements.indexOf(input); + if (e.shiftKey) { + // Shift+Tab: move to previous element + if (index > 0) { + elements[index - 1].focus(); + } + } else { + // Tab: move to next element + if (index > -1 && index < elements.length - 1) { + elements[index + 1].focus(); + } + } + }, 0); } }, [showTime, mode, onChange], 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..e3f75837 --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.stories.tsx @@ -0,0 +1,547 @@ +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"; + +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 + `, + }, + }, +}; diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx new file mode 100644 index 00000000..a113db91 --- /dev/null +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx @@ -0,0 +1,633 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { IMaskInput } from "react-imask"; +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 styled from "styled-components"; +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"; + +// Get config based on type +const getConfig = (type: DateMaskedInputType) => { + switch (type) { + case "date": + return MaskedDateConfig; + case "datetime": + return MaskedDateTimeConfig; + case "time": + return MaskedTimeConfig; + } +}; + +const InputWrapper = styled.div.attrs<{ + $required?: React.CSSProperties; + $disabled?: boolean; + $hasError?: boolean; +}>((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; +}>` + display: flex; + align-items: center; + width: 100%; + position: relative; + background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; + border-radius: 6px; +`; + +const StyledInput = styled(IMaskInput)<{ + $hasError?: boolean; + $required?: React.CSSProperties; + $isEmpty?: boolean; + $placeholderColor?: string; + $textColor?: 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 ? "#ff4d4f" : "#d9d9d9")}; + border-radius: 6px; + transition: all 0.2s; + font-family: inherit; + + &:focus { + border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; + box-shadow: 0 0 0 2px + ${(props) => + props.$hasError ? "rgba(255, 77, 79, 0.1)" : "rgba(5, 145, 255, 0.1)"}; + outline: none; + } + + &:hover { + border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; + } + + &:disabled { + color: rgba(0, 0, 0, 0.25); + background-color: rgba(0, 0, 0, 0.04); + cursor: not-allowed; + } +`; + +const SuffixIcon = styled.span<{ $allowClear?: boolean }>` + position: absolute; + right: 11px; + top: 50%; + transform: translateY(-50%); + color: rgba(0, 0, 0, 0.25); + 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 ? "rgba(0, 0, 0, 0.45)" : "rgba(0, 0, 0, 0.25)"}; + } +`; + +const ClearIcon = styled(SuffixIcon)` + opacity: 0; +`; + +const CalendarIcon = styled(SuffixIcon)` + opacity: 1; +`; + +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; + } +`; + +// Hidden picker container - only shows the dropdown, input is invisible +const HiddenPickerWrapper = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: hidden; + + /* Hide the picker input completely - use visibility:hidden instead of opacity + to ensure it doesn't interfere with selectors while still rendering the dropdown */ + > .ant-picker { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + visibility: hidden; + pointer-events: none; + } + + /* Ensure the dropdown (rendered in portal) remains visible */ + .ant-picker-dropdown { + visibility: visible; + } +`; + +const DateMaskedInput: React.FC = ( + props: DateMaskedInputProps, +) => { + 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 [pickerOpen, setPickerOpen] = useState(false); + const [parseError, setParseError] = useState(null); + const [inputValue, setInputValue] = useState(""); + + const datePickerLocale = useDatePickerLocale(); + const requiredStyle = useRequiredStyle(required, readOnly); + const effectivePlaceholder = placeholder || config.placeholder; + + // Check if the value prop is valid (for detecting invalid incoming values) + const isValuePropValid = useMemo(() => { + if (!value) return true; // Empty is valid (no error state) + + 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]); + + // Set error state when value prop is invalid (like DateInput does) + useEffect(() => { + if (value && !isValuePropValid) { + setParseError("Invalid date format"); + } else if (isValuePropValid && !inputValue) { + // Only clear error when value becomes valid AND there's no pending user input + setParseError(null); + } + }, [value, isValuePropValid, inputValue]); + + // Convert internal value to display format + 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; + + // Convert value to picker format + const pickerValue = useMemo(() => { + 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, timezone, type, config]); + + const handleAccept = useCallback((maskedValue: string) => { + setInputValue(maskedValue); + setParseError(null); + }, []); + + // Autocomplete based on type + 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) => { + // Treat empty, no digits, or placeholder-only values as clearing the field + const isEmpty = + !maskedValue || + !hasAnyDigits(maskedValue) || + maskedValue === effectivePlaceholder; + + if (isEmpty) { + onChange?.(null); + setInputValue(""); + setParseError(null); + return; + } + + if (isCompleteValue(maskedValue, effectivePlaceholder)) { + if (type === "time") { + // Time values are stored as-is (HH:mm:ss) + onChange?.(maskedValue); + setInputValue(""); + setParseError(null); + } else { + const internalValue = parseDisplayToInternal( + maskedValue, + config.displayFormat, + config.internalFormat, + ); + if (internalValue) { + onChange?.(internalValue); + setInputValue(""); + setParseError(null); + } else { + setParseError(`Invalid ${type}`); + } + } + return; + } + + const autocompleted = autocompleteFn(maskedValue); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + } else { + setParseError(`Invalid ${type} format`); + } + }, + [onChange, effectivePlaceholder, type, config, autocompleteFn], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + const maskedValue = input.value; + + // On Enter, if no digits entered, autocomplete to current date/time + if (!hasAnyDigits(maskedValue)) { + const autocompleted = autocompleteFn(""); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(""); + setParseError(null); + } + 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") { + // Close picker before Tab navigates away + e.preventDefault(); + const input = e.target as HTMLInputElement; + setPickerOpen(false); + commitValue(input.value); + + // Manually move focus to next/previous element based on Shift key + setTimeout(() => { + const focusableElements = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const elements = Array.from( + document.querySelectorAll(focusableElements), + ).filter((el) => { + // Exclude elements inside picker dropdown + return !el.closest(".ant-picker-dropdown"); + }) as HTMLElement[]; + const index = elements.indexOf(input); + if (e.shiftKey) { + // Shift+Tab: move to previous element + if (index > 0) { + elements[index - 1].focus(); + } + } else { + // Tab: move to next element + if (index > -1 && index < elements.length - 1) { + elements[index + 1].focus(); + } + } + }, 0); + } + }, + [commitValue, onChange, autocompleteFn], + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const input = e.target as HTMLInputElement; + commitValue(input.value); + }, + [commitValue], + ); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + // Check if focus is moving to an element inside the picker dropdown + // If so, don't close the picker (user is interacting with it) + 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 = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setPickerOpen(false); + setInputValue(""); + setParseError(null); + if (onChange) { + onChange(null); + } + }; + + // Handle picker change (when user selects from dropdown) + const handlePickerChange = useCallback( + (date: Dayjs | null) => { + if (!date) { + onChange?.(null); + setInputValue(""); + return; + } + + if (type === "time") { + onChange?.(date.format("HH:mm:ss")); + } else { + onChange?.(date.format(config.internalFormat)); + } + setInputValue(""); + setParseError(null); + + // For date-only mode, close picker after selection + if (type === "date") { + setPickerOpen(false); + skipNextFocusRef.current = true; + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + } + }, + [type, config, onChange], + ); + + // Handle OK button click in datetime/time mode + const handleOk = useCallback( + (date: Dayjs) => { + if (type === "time") { + onChange?.(date.format("HH:mm:ss")); + } else { + onChange?.(date.format(config.internalFormat)); + } + setInputValue(""); + setParseError(null); + setPickerOpen(false); + skipNextFocusRef.current = true; + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + }, + [type, config, onChange], + ); + + // Determine which icon to show + const Icon = type === "time" ? ClockCircleOutlined : CalendarOutlined; + + // Render the appropriate picker based on type + const renderPicker = () => { + const commonProps = { + open: pickerOpen, + onOpenChange: (open: boolean) => { + if (!open) { + setPickerOpen(false); + } + }, + value: pickerValue, + onChange: handlePickerChange, + locale: datePickerLocale, + getPopupContainer: () => wrapperRef.current || document.body, + showNow: false, + showToday: false, + allowClear: false, + inputReadOnly: true, + style: { width: "100%", height: "100%" }, + }; + + if (type === "time") { + return ; + } + + return ( + + ); + }; + + return ( + +
+ { + if (!readOnly) { + setPickerOpen(true); + inputRef.current?.focus(); + } + }} + > + + + {!readOnly && ( + { + setPickerOpen(true); + inputRef.current?.focus(); + }} + style={value ? undefined : { opacity: 1 }} + > + + + )} + {!readOnly && value && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + )} + + + + {/* Hidden picker - only used for its dropdown */} + {renderPicker()} +
+
+ ); +}; + +DateMaskedInput.displayName = "DateMaskedInput"; + +export { 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/MaskedDateInput/helpers/MaskedDate.helpers.ts b/src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.ts similarity index 100% rename from src/components/widgets/Date/MaskedDateInput/helpers/MaskedDate.helpers.ts rename to src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.ts 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/MaskedDateInput/MaskedDateInput.stories.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx deleted file mode 100644 index 2faf2a12..00000000 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.stories.tsx +++ /dev/null @@ -1,565 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Meta, StoryFn } from "@storybook/react"; -import { MaskedDateInput } from "./MaskedDateInput"; -import { Form, Typography } from "antd"; -import { MaskedDateInputProps } from "./MaskedDateInput.types"; - -const { Text, Paragraph } = Typography; - -type StoryArgs = MaskedDateInputProps & { - timezone?: string; -}; - -export default { - title: "Components/Widgets/Date/MaskedDateInput", - component: MaskedDateInput, - parameters: { - layout: "centered", - docs: { - description: { - component: ` -## MaskedDateInput - -A date input with DD/MM/YYYY mask that supports **partial entry with autocomplete**. - -### Key Features (from issue gisce/webclient#2291): - -1. **Masked Input**: Shows DD/MM/YYYY placeholder, guiding users on the expected format -2. **Partial Entry**: Users can enter just the day, or day/month - missing parts autocomplete from current date -3. **Smart Autocomplete**: - - Type "15" → autocompletes to "15/[current month]/[current year]" - - Type "15/06" → autocompletes to "15/06/[current year]" - - Triggers: Enter key, blur, or double-click -4. **Calendar Picker**: Click the calendar button for visual date selection -5. **Validation**: Invalid dates show error tooltip - -### Keyboard & Mouse Behaviors: - -| Action | Behavior | -|--------|----------| -| **Enter** | Autocompletes partial value and commits. On empty input, fills current date. | -| **Escape** | Commits current value and moves focus to next element. On empty input, fills current date. | -| **Blur** | Commits current value (no autocomplete on empty - use Enter/Escape for that) | -| **Double-click** | Autocompletes partial value and commits. On empty input, fills current date. | -| **Delete all + blur** | Clears the value (sets to null) | - `, - }, - }, - }, - argTypes: { - timezone: { - control: "select", - options: [ - "UTC", - "Europe/Madrid", - "Europe/London", - "America/New_York", - "Asia/Tokyo", - "Australia/Sydney", - ], - description: "Timezone for the date picker", - defaultValue: "Europe/Madrid", - }, - required: { - control: "boolean", - description: "Whether the field is required (shows yellow background)", - defaultValue: false, - }, - readOnly: { - control: "boolean", - description: "Whether the field is read-only", - defaultValue: false, - }, - }, -} as Meta; - -const Template: 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: -
-            {`Internal value: ${currentValue || "(empty)"}
-timezone: ${args.timezone}
-required: ${args.required?.toString()}
-readOnly: ${args.readOnly?.toString()}`}
-          
-
-
-
- ); -}; - -const AutocompleteDemo: StoryFn = (args) => { - const [form] = Form.useForm(); - const [value1, setValue1] = useState(); - const [value2, setValue2] = useState(); - const [value3, setValue3] = useState(); - - return ( -
- - Try these autocomplete scenarios: - - -
- - Type just day (e.g., "15") - - {" "} - → autocompletes month/year from today - - - } - > - setValue1(v || undefined)} - timezone="Europe/Madrid" - /> - {value1 && ( - - Result: {value1} - - )} - - - - Type day/month (e.g., "15/06") - → autocompletes year from today - - } - > - setValue2(v || undefined)} - timezone="Europe/Madrid" - /> - {value2 && ( - - Result: {value2} - - )} - - - - Type full date (e.g., "15/06/2024") - → no autocomplete needed - - } - > - setValue3(v || undefined)} - timezone="Europe/Madrid" - /> - {value3 && ( - - Result: {value3} - - )} - -
- -
- - Tip: Press Enter or click outside the field to - trigger autocomplete. Invalid dates will show an error tooltip. - -
-
- ); -}; - -// Main demo showing autocomplete functionality -export const AutocompleteFeature = AutocompleteDemo.bind({}); -AutocompleteFeature.args = { - timezone: "Europe/Madrid", -}; -AutocompleteFeature.parameters = { - docs: { - description: { - story: - "Demonstrates the key usability improvement: users can enter partial dates and the component autocompletes missing parts from the current date.", - }, - }, -}; - -// Basic usage with DD/MM/YYYY mask -export const Basic = Template.bind({}); -Basic.args = { - id: "basic-masked-date", - value: "2024-03-10", - required: false, - readOnly: false, - timezone: "Europe/Madrid", -}; - -// Empty state showing the mask placeholder -export const EmptyWithMask = Template.bind({}); -EmptyWithMask.args = { - id: "empty-masked-date", - value: undefined, - required: false, - readOnly: false, - timezone: "Europe/Madrid", -}; -EmptyWithMask.parameters = { - docs: { - description: { - story: - "Shows the DD/MM/YYYY mask placeholder when empty, guiding users on the expected format.", - }, - }, -}; - -// Required field with yellow background -export const Required = Template.bind({}); -Required.args = { - id: "required-masked-date", - value: "2024-03-10", - required: true, - readOnly: false, - timezone: "Europe/Madrid", -}; -Required.parameters = { - docs: { - description: { - story: - "Required fields show a yellow background to indicate they must be filled.", - }, - }, -}; - -// Read-only field -export const ReadOnly = Template.bind({}); -ReadOnly.args = { - id: "readonly-masked-date", - value: "2024-03-10", - required: false, - readOnly: true, - timezone: "Europe/Madrid", -}; -ReadOnly.parameters = { - docs: { - description: { - story: "Read-only mode disables the input and hides the calendar button.", - }, - }, -}; - -// Without calendar button - input only -export const WithoutCalendarButton = Template.bind({}); -WithoutCalendarButton.args = { - id: "no-calendar-masked-date", - value: "2024-03-10", - required: false, - readOnly: false, - timezone: "Europe/Madrid", -}; -WithoutCalendarButton.parameters = { - docs: { - description: { - story: - "The calendar button can be hidden for a more compact interface, relying only on keyboard input.", - }, - }, -}; - -// Different timezone - UTC -export const TimezoneUTC = Template.bind({}); -TimezoneUTC.args = { - id: "utc-masked-date", - value: "2024-03-10", - required: false, - readOnly: false, - timezone: "UTC", -}; - -// Different timezone - Tokyo -export const TimezoneTokyo = Template.bind({}); -TimezoneTokyo.args = { - id: "tokyo-masked-date", - value: "2024-03-10", - required: false, - readOnly: false, - timezone: "Asia/Tokyo", -}; - -// Comprehensive keyboard behavior demo -const KeyboardBehaviorDemo: StoryFn = () => { - const [values, setValues] = useState<{ - enterEmpty: string | undefined; - enter: string | undefined; - escape: string | undefined; - blur: string | undefined; - doubleClick: string | undefined; - clear: string | undefined; - }>({ - enterEmpty: undefined, - enter: undefined, - escape: undefined, - blur: undefined, - doubleClick: undefined, - clear: "2024-06-15", - }); - - const [logs, setLogs] = useState([]); - - const addLog = (message: string) => { - setLogs((prev) => [ - ...prev.slice(-9), - `${new Date().toLocaleTimeString()}: ${message}`, - ]); - }; - - return ( -
- - - Keyboard & Mouse Behavior Testing - - - - Test each input to verify the behavior described. Watch the event log - below. - - -
- - 1. Enter on Empty - - {" "} - - Focus and press Enter (should fill current date) - - - } - > - { - setValues((prev) => ({ ...prev, enterEmpty: v || undefined })); - addLog(`Enter empty field: value set to "${v}"`); - }} - timezone="Europe/Madrid" - /> - {values.enterEmpty && ( - → {values.enterEmpty} - )} - - - - 2. Enter Key (partial) - - Type "23" then press Enter - - } - > - { - setValues((prev) => ({ ...prev, enter: v || undefined })); - addLog(`Enter field: value set to "${v}"`); - }} - timezone="Europe/Madrid" - /> - {values.enter && → {values.enter}} - - - - 3. Escape Key - - {" "} - - Type "15/06" then press Escape (focus moves to next input) - - - } - > - { - setValues((prev) => ({ ...prev, escape: v || undefined })); - addLog(`Escape field: value set to "${v}"`); - }} - timezone="Europe/Madrid" - /> - {values.escape && → {values.escape}} - - - - 4. Blur (click outside) - - Type "10" then click outside - - } - > - { - setValues((prev) => ({ ...prev, blur: v || undefined })); - addLog(`Blur field: value set to "${v}"`); - }} - timezone="Europe/Madrid" - /> - {values.blur && → {values.blur}} - - - - 5. Double-click - - Type "25/12" then double-click - - } - > - { - setValues((prev) => ({ ...prev, doubleClick: v || undefined })); - addLog(`Double-click field: value set to "${v}"`); - }} - timezone="Europe/Madrid" - /> - {values.doubleClick && ( - → {values.doubleClick} - )} - - - - 6. Clear value - - {" "} - - Delete all content and click outside (should become null) - - - } - > - { - setValues((prev) => ({ ...prev, clear: v || undefined })); - addLog(`Clear field: value set to "${v ?? "null"}"`); - }} - timezone="Europe/Madrid" - /> - - {" "} - → {values.clear || "(empty/null)"} - - -
- -
- Event Log: - {logs.length === 0 ? ( -
No events yet...
- ) : ( - logs.map((log, i) =>
{log}
) - )} -
-
- ); -}; - -export const KeyboardBehaviors = KeyboardBehaviorDemo.bind({}); -KeyboardBehaviors.parameters = { - docs: { - description: { - story: ` -Interactive demo to test all keyboard and mouse behaviors: - -1. **Enter on Empty**: Focus empty input and press Enter - fills current date -2. **Enter (partial)**: Autocompletes partial date and commits -3. **Escape**: Commits and moves focus to next focusable element -4. **Blur**: Commits partial date (blur on empty clears value) -5. **Double-click**: Same as Enter - autocompletes and commits -6. **Clear**: Delete all content and blur to clear the value - `, - }, - }, -}; diff --git a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx deleted file mode 100644 index 768244c3..00000000 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; -import { IMaskInput } from "react-imask"; -import { DatePicker, Tooltip } from "antd"; -import { CalendarOutlined, CloseCircleFilled } from "@ant-design/icons"; -import dayjs from "dayjs"; -import styled from "styled-components"; -import { MaskedDateInputProps } from "./MaskedDateInput.types"; -import { - MaskedDateConfig, - autocompleteDate, - parseInternalToDisplay, - parseDisplayToInternal, - isCompleteValue, - hasAnyDigits, -} from "./helpers/MaskedDate.helpers"; -import { useRequiredStyle } from "@/hooks/useRequiredStyle"; -import { useDatePickerLocale } from "../DateInput/hooks/useDatePickerLocale"; - -const InputWrapper = styled.div` - display: flex; - align-items: center; - width: 100%; - gap: 4px; - position: relative; -`; - -const StyledInput = styled(IMaskInput)<{ - $hasError?: boolean; - $required?: React.CSSProperties; - $isEmpty?: boolean; -}>` - flex: 1; - width: 100%; - height: 32px; - padding: 4px 11px; - font-size: 14px; - line-height: 1.5715; - color: ${(props) => - props.$isEmpty ? "rgba(0, 0, 0, 0.25)" : "rgba(0, 0, 0, 0.88)"}; - background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; - border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; - border-radius: 6px; - transition: all 0.2s; - font-family: inherit; - - &:focus { - border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; - box-shadow: 0 0 0 2px - ${(props) => - props.$hasError ? "rgba(255, 77, 79, 0.1)" : "rgba(5, 145, 255, 0.1)"}; - outline: none; - } - - &:hover { - border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; - } - - &:disabled { - color: rgba(0, 0, 0, 0.25); - background-color: rgba(0, 0, 0, 0.04); - cursor: not-allowed; - } -`; - -const SuffixIcon = styled.span<{ $allowClear?: boolean }>` - position: absolute; - right: 11px; - top: 50%; - transform: translateY(-50%); - color: rgba(0, 0, 0, 0.25); - cursor: ${(props) => (props.$allowClear ? "pointer" : "default")}; - transition: color 0.2s; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - color: ${(props) => - props.$allowClear ? "rgba(0, 0, 0, 0.45)" : "rgba(0, 0, 0, 0.25)"}; - } -`; - -const InputContainer = styled.div` - position: relative; - flex: 1; - display: flex; - align-items: center; -`; - -const HiddenPickerTrigger = styled(DatePicker)` - position: absolute; - left: 0; - top: 100%; - opacity: 0; - pointer-events: none; - width: 1px; - height: 1px; -`; - -const MaskedDateInput: React.FC = memo( - (props: MaskedDateInputProps) => { - const { - value, - onChange, - id, - readOnly = false, - required = false, - timezone = "Europe/Madrid", - placeholder = MaskedDateConfig.placeholder, - } = props; - - const inputRef = useRef(null); - const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( - null, - ); - const [calendarOpen, setCalendarOpen] = useState(false); - const [parseError, setParseError] = useState(null); - const [inputValue, setInputValue] = useState(""); - const [isHovered, setIsHovered] = useState(false); - - const datePickerLocale = useDatePickerLocale(); - const requiredStyle = useRequiredStyle(required, readOnly); - - const displayValue = useMemo(() => { - if (!value) return ""; - return parseInternalToDisplay( - value, - MaskedDateConfig.internalFormat, - MaskedDateConfig.displayFormat, - timezone, - ); - }, [value, timezone]); - - const currentInputValue = inputValue || displayValue; - - const handleAccept = useCallback((maskedValue: string) => { - setInputValue(maskedValue); - setParseError(null); - }, []); - - const commitValue = useCallback( - (maskedValue: string) => { - if (!maskedValue || !hasAnyDigits(maskedValue)) { - onChange?.(null); - setInputValue(""); - setParseError(null); - return; - } - - if (isCompleteValue(maskedValue, placeholder)) { - const internalValue = parseDisplayToInternal( - maskedValue, - MaskedDateConfig.displayFormat, - MaskedDateConfig.internalFormat, - ); - if (internalValue) { - onChange?.(internalValue); - setInputValue(""); - setParseError(null); - } else { - setParseError("Invalid date"); - } - return; - } - - const autocompleted = autocompleteDate(maskedValue); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(""); - setParseError(null); - } else { - setParseError("Invalid date format"); - } - }, - [onChange, placeholder], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - const input = e.target as HTMLInputElement; - const maskedValue = input.value; - - // On Enter, if no digits entered, autocomplete to current date - if (!hasAnyDigits(maskedValue)) { - const autocompleted = autocompleteDate(""); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(""); - setParseError(null); - } - return; - } - - commitValue(maskedValue); - } else if (e.key === "Escape") { - e.preventDefault(); - const input = e.target as HTMLInputElement; - - 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); - } - }, - [commitValue, onChange], - ); - - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - const input = e.target as HTMLInputElement; - commitValue(input.value); - }, - [commitValue], - ); - - const handleBlur = useCallback( - (e: React.FocusEvent) => { - // Close calendar and commit value - setCalendarOpen(false); - commitValue(e.target.value); - }, - [commitValue], - ); - - const handleCalendarChange = useCallback( - (date: unknown, dateString: string | string[]) => { - setCalendarOpen(false); - if (date && dayjs.isDayjs(date)) { - const internalValue = date.format(MaskedDateConfig.internalFormat); - onChange?.(internalValue); - setInputValue(""); - setParseError(null); - } - setTimeout(() => { - inputRef.current?.focus(); - }, 50); - }, - [onChange], - ); - - const handleFocus = useCallback(() => { - if (!readOnly) { - setCalendarOpen(true); - } - }, [readOnly]); - - const handleClear = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onChange?.(null); - setInputValue(""); - setParseError(null); - inputRef.current?.focus(); - }, - [onChange], - ); - - const pickerValue = useMemo(() => { - if (!value) return undefined; - try { - const parsed = timezone - ? dayjs.tz(value, MaskedDateConfig.internalFormat, timezone) - : dayjs(value, MaskedDateConfig.internalFormat); - return parsed.isValid() ? parsed : undefined; - } catch { - return undefined; - } - }, [value, timezone]); - - return ( - - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - - {!readOnly && ( - - {value && isHovered ? ( - - ) : ( - - )} - - )} - - - - - ); - }, -); - -MaskedDateInput.displayName = "MaskedDateInput"; - -export { MaskedDateInput }; diff --git a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts b/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts deleted file mode 100644 index 5dbf789a..00000000 --- a/src/components/widgets/Date/MaskedDateInput/MaskedDateInput.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type MaskedDateInputProps = { - value?: string; - onChange?: (value: string | null | undefined) => void; - id: string; - readOnly?: boolean; - required?: boolean; - timezone?: string; - placeholder?: string; -}; diff --git a/src/components/widgets/Date/MaskedDateInput/index.ts b/src/components/widgets/Date/MaskedDateInput/index.ts deleted file mode 100644 index a7cb417a..00000000 --- a/src/components/widgets/Date/MaskedDateInput/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./MaskedDateInput"; -export * from "./MaskedDateInput.types"; -export * from "./helpers/MaskedDate.helpers"; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx deleted file mode 100644 index cf2c88c1..00000000 --- a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.stories.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Meta, StoryFn } from "@storybook/react"; -import { MaskedDateTimeInput } from "./MaskedDateTimeInput"; -import { Form, Typography } from "antd"; -import { MaskedDateTimeInputProps } from "./MaskedDateTimeInput.types"; - -const { Text, Paragraph } = Typography; - -type StoryArgs = MaskedDateTimeInputProps & { - timezone?: string; -}; - -export default { - title: "Components/Widgets/Date/MaskedDateTimeInput", - component: MaskedDateTimeInput, - parameters: { - layout: "centered", - docs: { - description: { - component: ` -## MaskedDateTimeInput - -A datetime input with DD/MM/YYYY HH:mm:ss mask that supports **partial entry with autocomplete**. - -### Key Features (from issue gisce/webclient#2291): - -1. **Masked Input**: Shows DD/MM/YYYY HH:mm:ss placeholder -2. **Partial Entry**: Users can enter just the date part, or partial time - missing parts autocomplete -3. **Smart Autocomplete**: - - Type "15" → autocompletes to "15/[current month]/[current year] [current time]" - - Type "15/06" → autocompletes to "15/06/[current year] [current time]" - - Type "15/06/2024 14" → autocompletes to "15/06/2024 14:[current min]:[current sec]" - - Type "15/06/2024 14:30" → autocompletes to "15/06/2024 14:30:[current sec]" -4. **Calendar Picker**: Click the calendar button for visual date/time selection -5. **Validation**: Invalid dates show error tooltip - -### This solves the usability issue: -Users no longer need to select all datetime fields just to change one value. -For example, to change only the minutes, they can type the hour and minutes, -and the seconds will autocomplete from the current time. - `, - }, - }, - }, - argTypes: { - timezone: { - control: "select", - options: [ - "UTC", - "Europe/Madrid", - "Europe/London", - "America/New_York", - "Asia/Tokyo", - "Australia/Sydney", - ], - description: "Timezone for the datetime picker", - defaultValue: "Europe/Madrid", - }, - required: { - control: "boolean", - description: "Whether the field is required (shows yellow background)", - defaultValue: false, - }, - readOnly: { - control: "boolean", - description: "Whether the field is read-only", - defaultValue: false, - }, - }, -} as Meta; - -const Template: StoryFn = (args) => { - const [form] = Form.useForm(); - const fieldName = args.id || "dateTimeField"; - 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: -
-            {`Internal value: ${currentValue || "(empty)"}
-timezone: ${args.timezone}
-required: ${args.required?.toString()}
-readOnly: ${args.readOnly?.toString()}`}
-          
-
-
-
- ); -}; - -const AutocompleteDemo: StoryFn = (args) => { - const [form] = Form.useForm(); - const [value1, setValue1] = useState(); - const [value2, setValue2] = useState(); - const [value3, setValue3] = useState(); - const [value4, setValue4] = useState(); - - return ( -
- - - Try these autocomplete scenarios (solves gisce/webclient#2291): - - - -
- - Type just day (e.g., "15") - - {" "} - → autocompletes month/year/time from now - - - } - > - setValue1(v || undefined)} - timezone="Europe/Madrid" - /> - {value1 && ( - - Result: {value1} - - )} - - - - Type day/month (e.g., "15/06") - → autocompletes year/time from now - - } - > - setValue2(v || undefined)} - timezone="Europe/Madrid" - /> - {value2 && ( - - Result: {value2} - - )} - - - - Type date + hour (e.g., "15/06/2024 14") - → autocompletes min:sec - - } - > - setValue3(v || undefined)} - timezone="Europe/Madrid" - /> - {value3 && ( - - Result: {value3} - - )} - - - - Type date + hour:min (e.g., "15/06/2024 14:30") - → autocompletes seconds - - } - > - setValue4(v || undefined)} - timezone="Europe/Madrid" - /> - {value4 && ( - - Result: {value4} - - )} - -
- -
- - Usability improvement: You no longer need to fill in - all fields. Just change what you need and the rest autocompletes from - current date/time. - -
-
- ); -}; - -// Main demo showing autocomplete functionality - the key usability improvement -export const AutocompleteFeature = AutocompleteDemo.bind({}); -AutocompleteFeature.args = { - timezone: "Europe/Madrid", -}; -AutocompleteFeature.parameters = { - docs: { - description: { - story: - "**Main feature**: Demonstrates partial entry with autocomplete. Users can change just the fields they need without filling everything.", - }, - }, -}; - -// Basic usage with DD/MM/YYYY HH:mm:ss mask -export const Basic = Template.bind({}); -Basic.args = { - id: "basic-masked-datetime", - value: "2024-03-10 14:30:00", - required: false, - readOnly: false, - timezone: "Europe/Madrid", -}; - -// Empty state showing the mask placeholder -export const EmptyWithMask = Template.bind({}); -EmptyWithMask.args = { - id: "empty-masked-datetime", - value: undefined, - required: false, - readOnly: false, - timezone: "Europe/Madrid", -}; -EmptyWithMask.parameters = { - docs: { - description: { - story: "Shows the DD/MM/YYYY HH:mm:ss mask placeholder when empty.", - }, - }, -}; - -// Required field with yellow background -export const Required = Template.bind({}); -Required.args = { - id: "required-masked-datetime", - value: "2024-03-10 14:30:00", - required: true, - readOnly: false, - timezone: "Europe/Madrid", -}; - -// Read-only field -export const ReadOnly = Template.bind({}); -ReadOnly.args = { - id: "readonly-masked-datetime", - value: "2024-03-10 14:30:00", - required: false, - readOnly: true, - timezone: "Europe/Madrid", -}; - -// Without calendar button -export const WithoutCalendarButton = Template.bind({}); -WithoutCalendarButton.args = { - id: "no-calendar-masked-datetime", - value: "2024-03-10 14:30:00", - required: false, - readOnly: false, - timezone: "Europe/Madrid", -}; - -// Different timezone - UTC -export const TimezoneUTC = Template.bind({}); -TimezoneUTC.args = { - id: "utc-masked-datetime", - value: "2024-03-10 14:30:00", - required: false, - readOnly: false, - timezone: "UTC", -}; - -// Different timezone - Tokyo -export const TimezoneTokyo = Template.bind({}); -TimezoneTokyo.args = { - id: "tokyo-masked-datetime", - value: "2024-03-10 14:30:00", - required: false, - readOnly: false, - timezone: "Asia/Tokyo", -}; - -// Midnight time -export const Midnight = Template.bind({}); -Midnight.args = { - id: "midnight-masked-datetime", - value: "2024-03-10 00:00:00", - required: false, - readOnly: false, - timezone: "Europe/Madrid", -}; - -// End of day -export const EndOfDay = Template.bind({}); -EndOfDay.args = { - id: "endofday-masked-datetime", - value: "2024-03-10 23:59:59", - required: false, - readOnly: false, - timezone: "Europe/Madrid", -}; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx deleted file mode 100644 index 0f83edc6..00000000 --- a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; -import { IMaskInput } from "react-imask"; -import { DatePicker, Tooltip } from "antd"; -import { CalendarOutlined, CloseCircleFilled } from "@ant-design/icons"; -import dayjs from "dayjs"; -import styled from "styled-components"; -import { MaskedDateTimeInputProps } from "./MaskedDateTimeInput.types"; -import { - MaskedDateTimeConfig, - autocompleteDateTime, - parseInternalToDisplay, - parseDisplayToInternal, - isCompleteValue, - hasAnyDigits, -} from "../MaskedDateInput/helpers/MaskedDate.helpers"; -import { useRequiredStyle } from "@/hooks/useRequiredStyle"; -import { useDatePickerLocale } from "../DateInput/hooks/useDatePickerLocale"; - -const InputWrapper = styled.div` - display: flex; - align-items: center; - width: 100%; - gap: 4px; - position: relative; -`; - -const StyledInput = styled(IMaskInput)<{ - $hasError?: boolean; - $required?: React.CSSProperties; - $isEmpty?: boolean; -}>` - flex: 1; - width: 100%; - height: 32px; - padding: 4px 11px; - font-size: 14px; - line-height: 1.5715; - color: ${(props) => - props.$isEmpty ? "rgba(0, 0, 0, 0.25)" : "rgba(0, 0, 0, 0.88)"}; - background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; - border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; - border-radius: 6px; - transition: all 0.2s; - font-family: inherit; - - &:focus { - border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; - box-shadow: 0 0 0 2px - ${(props) => - props.$hasError ? "rgba(255, 77, 79, 0.1)" : "rgba(5, 145, 255, 0.1)"}; - outline: none; - } - - &:hover { - border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; - } - - &:disabled { - color: rgba(0, 0, 0, 0.25); - background-color: rgba(0, 0, 0, 0.04); - cursor: not-allowed; - } -`; - -const SuffixIcon = styled.span<{ $allowClear?: boolean }>` - position: absolute; - right: 11px; - top: 50%; - transform: translateY(-50%); - color: rgba(0, 0, 0, 0.25); - cursor: ${(props) => (props.$allowClear ? "pointer" : "default")}; - transition: color 0.2s; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - color: ${(props) => - props.$allowClear ? "rgba(0, 0, 0, 0.45)" : "rgba(0, 0, 0, 0.25)"}; - } -`; - -const InputContainer = styled.div` - position: relative; - flex: 1; - display: flex; - align-items: center; -`; - -const HiddenPickerTrigger = styled(DatePicker)` - position: absolute; - left: 0; - top: 100%; - opacity: 0; - pointer-events: none; - width: 1px; - height: 1px; -`; - -const MaskedDateTimeInput: React.FC = memo( - (props: MaskedDateTimeInputProps) => { - const { - value, - onChange, - id, - readOnly = false, - required = false, - timezone = "Europe/Madrid", - placeholder = MaskedDateTimeConfig.placeholder, - } = props; - - const inputRef = useRef(null); - const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( - null, - ); - const [calendarOpen, setCalendarOpen] = useState(false); - const [parseError, setParseError] = useState(null); - const [inputValue, setInputValue] = useState(""); - const [isHovered, setIsHovered] = useState(false); - - const datePickerLocale = useDatePickerLocale(); - const requiredStyle = useRequiredStyle(required, readOnly); - - const displayValue = useMemo(() => { - if (!value) return ""; - return parseInternalToDisplay( - value, - MaskedDateTimeConfig.internalFormat, - MaskedDateTimeConfig.displayFormat, - timezone, - ); - }, [value, timezone]); - - const currentInputValue = inputValue || displayValue; - - const handleAccept = useCallback((maskedValue: string) => { - setInputValue(maskedValue); - setParseError(null); - }, []); - - const commitValue = useCallback( - (maskedValue: string) => { - if (!maskedValue || !hasAnyDigits(maskedValue)) { - onChange?.(null); - setInputValue(""); - setParseError(null); - return; - } - - if (isCompleteValue(maskedValue, placeholder)) { - const internalValue = parseDisplayToInternal( - maskedValue, - MaskedDateTimeConfig.displayFormat, - MaskedDateTimeConfig.internalFormat, - ); - if (internalValue) { - onChange?.(internalValue); - setInputValue(""); - setParseError(null); - } else { - setParseError("Invalid date/time"); - } - return; - } - - const autocompleted = autocompleteDateTime(maskedValue); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(""); - setParseError(null); - } else { - setParseError("Invalid date/time format"); - } - }, - [onChange, placeholder], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - const input = e.target as HTMLInputElement; - const maskedValue = input.value; - - // On Enter, if no digits entered, autocomplete to current datetime - if (!hasAnyDigits(maskedValue)) { - const autocompleted = autocompleteDateTime(""); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(""); - setParseError(null); - } - return; - } - - commitValue(maskedValue); - } else if (e.key === "Escape") { - e.preventDefault(); - const input = e.target as HTMLInputElement; - - 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); - } - }, - [commitValue, onChange], - ); - - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - const input = e.target as HTMLInputElement; - commitValue(input.value); - }, - [commitValue], - ); - - const handleBlur = useCallback( - (e: React.FocusEvent) => { - // Close calendar and commit value - setCalendarOpen(false); - commitValue(e.target.value); - }, - [commitValue], - ); - - const handleCalendarChange = useCallback( - (date: unknown, dateString: string | string[]) => { - setCalendarOpen(false); - if (date && dayjs.isDayjs(date)) { - const internalValue = date.format( - MaskedDateTimeConfig.internalFormat, - ); - onChange?.(internalValue); - setInputValue(""); - setParseError(null); - } - setTimeout(() => { - inputRef.current?.focus(); - }, 50); - }, - [onChange], - ); - - const handleFocus = useCallback(() => { - if (!readOnly) { - setCalendarOpen(true); - } - }, [readOnly]); - - const handleClear = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onChange?.(null); - setInputValue(""); - setParseError(null); - inputRef.current?.focus(); - }, - [onChange], - ); - - const pickerValue = useMemo(() => { - if (!value) return undefined; - try { - const parsed = timezone - ? dayjs.tz(value, MaskedDateTimeConfig.internalFormat, timezone) - : dayjs(value, MaskedDateTimeConfig.internalFormat); - return parsed.isValid() ? parsed : undefined; - } catch { - return undefined; - } - }, [value, timezone]); - - return ( - - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - - {!readOnly && ( - - {value && isHovered ? ( - - ) : ( - - )} - - )} - - - - - ); - }, -); - -MaskedDateTimeInput.displayName = "MaskedDateTimeInput"; - -export { MaskedDateTimeInput }; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts b/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts deleted file mode 100644 index 3220585a..00000000 --- a/src/components/widgets/Date/MaskedDateTimeInput/MaskedDateTimeInput.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type MaskedDateTimeInputProps = { - value?: string; - onChange?: (value: string | null | undefined) => void; - id: string; - readOnly?: boolean; - required?: boolean; - timezone?: string; - placeholder?: string; -}; diff --git a/src/components/widgets/Date/MaskedDateTimeInput/index.ts b/src/components/widgets/Date/MaskedDateTimeInput/index.ts deleted file mode 100644 index ad92fe63..00000000 --- a/src/components/widgets/Date/MaskedDateTimeInput/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./MaskedDateTimeInput"; -export * from "./MaskedDateTimeInput.types"; diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx deleted file mode 100644 index 96f8695c..00000000 --- a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.stories.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Meta, StoryFn } from "@storybook/react"; -import { MaskedTimeInput } from "./MaskedTimeInput"; -import { Form, Typography } from "antd"; -import { MaskedTimeInputProps } from "./MaskedTimeInput.types"; - -const { Text, Paragraph } = Typography; - -type StoryArgs = MaskedTimeInputProps; - -export default { - title: "Components/Widgets/Date/MaskedTimeInput", - component: MaskedTimeInput, - parameters: { - layout: "centered", - docs: { - description: { - component: ` -## MaskedTimeInput - -A time input with HH:mm:ss mask that supports **partial entry with autocomplete**. - -### Key Features (from issue gisce/webclient#2291): - -1. **Masked Input**: Shows HH:mm:ss placeholder -2. **Partial Entry**: Users can enter just hours, or hours:minutes - missing parts autocomplete -3. **Smart Autocomplete**: - - Type "14" → autocompletes to "14:[current min]:[current sec]" - - Type "14:30" → autocompletes to "14:30:[current sec]" -4. **useZeros Option**: When enabled, autocompletes with zeros instead of current time - - Type "14" with useZeros → "14:00:00" -5. **Clock Picker**: Click the clock button for visual time selection -6. **Validation**: Invalid times show error tooltip - -### This solves the usability issue: -Users no longer need to select hours, minutes AND seconds just to change one value. -For example, to change only the minutes, they type hour:minutes and seconds autocomplete. - `, - }, - }, - }, - argTypes: { - required: { - control: "boolean", - description: "Whether the field is required (shows yellow background)", - defaultValue: false, - }, - readOnly: { - control: "boolean", - description: "Whether the field is read-only", - defaultValue: false, - }, - useZeros: { - control: "boolean", - description: - "When autocompleting, fill missing parts with zeros instead of current time", - defaultValue: false, - }, - }, -} as Meta; - -const Template: StoryFn = (args) => { - const [form] = Form.useForm(); - const fieldName = args.id || "timeField"; - 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: -
-            {`Internal value: ${currentValue || "(empty)"}
-required: ${args.required?.toString()}
-readOnly: ${args.readOnly?.toString()}
-useZeros: ${args.useZeros?.toString()}`}
-          
-
-
-
- ); -}; - -const AutocompleteDemo: StoryFn = (args) => { - const [form] = Form.useForm(); - const [value1, setValue1] = useState(); - const [value2, setValue2] = useState(); - const [value3, setValue3] = useState(); - const [value4, setValue4] = useState(); - - return ( -
- - - Try these autocomplete scenarios (solves gisce/webclient#2291): - - - -
- - Type just hour (e.g., "14") - → autocompletes min:sec from now - - } - > - setValue1(v || undefined)} - useZeros={false} - /> - {value1 && ( - - Result: {value1} - - )} - - - - Type hour:min (e.g., "14:30") - → autocompletes seconds from now - - } - > - setValue2(v || undefined)} - useZeros={false} - /> - {value2 && ( - - Result: {value2} - - )} - - - - With useZeros: Type "14" - → autocompletes to "14:00:00" - - } - > - setValue3(v || undefined)} - useZeros={true} - /> - {value3 && ( - - Result: {value3} - - )} - - - - Full time (e.g., "14:30:45") - → no autocomplete needed - - } - > - setValue4(v || undefined)} - /> - {value4 && ( - - Result: {value4} - - )} - -
- -
- - Usability improvement: You no longer need to fill in - hours, minutes, AND seconds. Just change what you need and the rest - autocompletes. - -
-
- ); -}; - -// Main demo showing autocomplete functionality - the key usability improvement -export const AutocompleteFeature = AutocompleteDemo.bind({}); -AutocompleteFeature.args = {}; -AutocompleteFeature.parameters = { - docs: { - description: { - story: - "**Main feature**: Demonstrates partial entry with autocomplete. Users can change just the fields they need without filling everything.", - }, - }, -}; - -// Basic usage with HH:mm:ss mask -export const Basic = Template.bind({}); -Basic.args = { - id: "basic-masked-time", - value: "14:30:00", - required: false, - readOnly: false, - useZeros: false, -}; - -// Empty state showing the mask placeholder -export const EmptyWithMask = Template.bind({}); -EmptyWithMask.args = { - id: "empty-masked-time", - value: undefined, - required: false, - readOnly: false, - useZeros: false, -}; -EmptyWithMask.parameters = { - docs: { - description: { - story: "Shows the HH:mm:ss mask placeholder when empty.", - }, - }, -}; - -// Required field with yellow background -export const Required = Template.bind({}); -Required.args = { - id: "required-masked-time", - value: "14:30:00", - required: true, - readOnly: false, - useZeros: false, -}; - -// Read-only field -export const ReadOnly = Template.bind({}); -ReadOnly.args = { - id: "readonly-masked-time", - value: "14:30:00", - required: false, - readOnly: true, - useZeros: false, -}; - -// Without clock button -export const WithoutClockButton = Template.bind({}); -WithoutClockButton.args = { - id: "no-clock-masked-time", - value: "14:30:00", - required: false, - readOnly: false, - useZeros: false, -}; - -// With useZeros - autocomplete fills with zeros instead of current time -export const WithUseZeros = Template.bind({}); -WithUseZeros.args = { - id: "zeros-masked-time", - value: undefined, - required: false, - readOnly: false, - useZeros: true, -}; -WithUseZeros.parameters = { - docs: { - description: { - story: - "With useZeros enabled, partial entries autocomplete with :00 instead of current time. Try typing just '14' and pressing Enter.", - }, - }, -}; - -// Midnight time -export const Midnight = Template.bind({}); -Midnight.args = { - id: "midnight-masked-time", - value: "00:00:00", - required: false, - readOnly: false, - useZeros: false, -}; - -// End of day -export const EndOfDay = Template.bind({}); -EndOfDay.args = { - id: "endofday-masked-time", - value: "23:59:59", - required: false, - readOnly: false, - useZeros: false, -}; diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx deleted file mode 100644 index 909c1e66..00000000 --- a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; -import { IMaskInput } from "react-imask"; -import { TimePicker, Tooltip } from "antd"; -import { ClockCircleOutlined, CloseCircleFilled } from "@ant-design/icons"; -import dayjs, { Dayjs } from "dayjs"; -import styled from "styled-components"; -import { MaskedTimeInputProps } from "./MaskedTimeInput.types"; -import { - MaskedTimeConfig, - autocompleteTime, - isCompleteValue, - hasAnyDigits, -} from "../MaskedDateInput/helpers/MaskedDate.helpers"; -import { useRequiredStyle } from "@/hooks/useRequiredStyle"; - -const InputWrapper = styled.div` - display: flex; - align-items: center; - width: 100%; - gap: 4px; - position: relative; -`; - -const StyledInput = styled(IMaskInput)<{ - $hasError?: boolean; - $required?: React.CSSProperties; - $isEmpty?: boolean; -}>` - flex: 1; - width: 100%; - height: 32px; - padding: 4px 11px; - font-size: 14px; - line-height: 1.5715; - color: ${(props) => - props.$isEmpty ? "rgba(0, 0, 0, 0.25)" : "rgba(0, 0, 0, 0.88)"}; - background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; - border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; - border-radius: 6px; - transition: all 0.2s; - font-family: inherit; - - &:focus { - border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; - box-shadow: 0 0 0 2px - ${(props) => - props.$hasError ? "rgba(255, 77, 79, 0.1)" : "rgba(5, 145, 255, 0.1)"}; - outline: none; - } - - &:hover { - border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; - } - - &:disabled { - color: rgba(0, 0, 0, 0.25); - background-color: rgba(0, 0, 0, 0.04); - cursor: not-allowed; - } -`; - -const SuffixIcon = styled.span<{ $allowClear?: boolean }>` - position: absolute; - right: 11px; - top: 50%; - transform: translateY(-50%); - color: rgba(0, 0, 0, 0.25); - cursor: ${(props) => (props.$allowClear ? "pointer" : "default")}; - transition: color 0.2s; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - - &:hover { - color: ${(props) => - props.$allowClear ? "rgba(0, 0, 0, 0.45)" : "rgba(0, 0, 0, 0.25)"}; - } -`; - -const InputContainer = styled.div` - position: relative; - flex: 1; - display: flex; - align-items: center; -`; - -const HiddenPickerTrigger = styled(TimePicker)` - position: absolute; - left: 0; - top: 100%; - opacity: 0; - pointer-events: none; - width: 1px; - height: 1px; -`; - -const MaskedTimeInput: React.FC = memo( - (props: MaskedTimeInputProps) => { - const { - value, - onChange, - id, - readOnly = false, - required = false, - placeholder = MaskedTimeConfig.placeholder, - useZeros = false, - } = props; - - const inputRef = useRef(null); - const pickerRef = useRef<{ blur: () => void; focus: () => void } | null>( - null, - ); - const [pickerOpen, setPickerOpen] = useState(false); - const [parseError, setParseError] = useState(null); - const [inputValue, setInputValue] = useState(""); - const [isHovered, setIsHovered] = useState(false); - - const requiredStyle = useRequiredStyle(required, readOnly); - - const displayValue = useMemo(() => { - if (!value) return ""; - try { - const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); - return parsed.isValid() - ? parsed.format(MaskedTimeConfig.displayFormat) - : ""; - } catch { - return ""; - } - }, [value]); - - const currentInputValue = inputValue || displayValue; - - const handleAccept = useCallback((maskedValue: string) => { - setInputValue(maskedValue); - setParseError(null); - }, []); - - const commitValue = useCallback( - (maskedValue: string) => { - if (!maskedValue || !hasAnyDigits(maskedValue)) { - onChange?.(null); - setInputValue(""); - setParseError(null); - return; - } - - if (isCompleteValue(maskedValue, placeholder)) { - onChange?.(maskedValue); - setInputValue(""); - setParseError(null); - return; - } - - const autocompleted = autocompleteTime(maskedValue, dayjs(), useZeros); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(""); - setParseError(null); - } else { - setParseError("Invalid time format"); - } - }, - [onChange, placeholder, useZeros], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - const input = e.target as HTMLInputElement; - const maskedValue = input.value; - - // On Enter, if no digits entered, autocomplete to current time - if (!hasAnyDigits(maskedValue)) { - const autocompleted = autocompleteTime("", dayjs(), useZeros); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(""); - setParseError(null); - } - return; - } - - commitValue(maskedValue); - } else if (e.key === "Escape") { - e.preventDefault(); - const input = e.target as HTMLInputElement; - - 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); - } - }, - [commitValue, onChange, useZeros], - ); - - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - const input = e.target as HTMLInputElement; - commitValue(input.value); - }, - [commitValue], - ); - - const handleBlur = useCallback( - (e: React.FocusEvent) => { - // Close picker and commit value - setPickerOpen(false); - commitValue(e.target.value); - }, - [commitValue], - ); - - const handlePickerChange = useCallback( - (time: Dayjs | null) => { - setPickerOpen(false); - if (time) { - const timeValue = time.format(MaskedTimeConfig.internalFormat); - onChange?.(timeValue); - setInputValue(""); - setParseError(null); - } - setTimeout(() => { - inputRef.current?.focus(); - }, 50); - }, - [onChange], - ); - - const handleFocus = useCallback(() => { - if (!readOnly) { - setPickerOpen(true); - } - }, [readOnly]); - - const handleClear = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onChange?.(null); - setInputValue(""); - setParseError(null); - inputRef.current?.focus(); - }, - [onChange], - ); - - const pickerValue = useMemo(() => { - if (!value) return undefined; - try { - const parsed = dayjs(`2000-01-01 ${value}`, "YYYY-MM-DD HH:mm:ss"); - return parsed.isValid() ? parsed : undefined; - } catch { - return undefined; - } - }, [value]); - - return ( - - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - - - {!readOnly && ( - - {value && isHovered ? ( - - ) : ( - - )} - - )} - - - - - ); - }, -); - -MaskedTimeInput.displayName = "MaskedTimeInput"; - -export { MaskedTimeInput }; diff --git a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts b/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts deleted file mode 100644 index 25ad04c2..00000000 --- a/src/components/widgets/Date/MaskedTimeInput/MaskedTimeInput.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type MaskedTimeInputProps = { - value?: string; - onChange?: (value: string | null | undefined) => void; - id?: string; - readOnly?: boolean; - required?: boolean; - placeholder?: string; - useZeros?: boolean; -}; diff --git a/src/components/widgets/Date/MaskedTimeInput/index.ts b/src/components/widgets/Date/MaskedTimeInput/index.ts deleted file mode 100644 index 4ffadf8b..00000000 --- a/src/components/widgets/Date/MaskedTimeInput/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./MaskedTimeInput"; -export * from "./MaskedTimeInput.types"; diff --git a/src/components/widgets/Date/index.ts b/src/components/widgets/Date/index.ts index ee62335f..9b016c76 100644 --- a/src/components/widgets/Date/index.ts +++ b/src/components/widgets/Date/index.ts @@ -1,6 +1,4 @@ export * from "./DateInput"; export * from "./DateValue"; export * from "./DateTimeValue"; -export * from "./MaskedDateInput"; -export * from "./MaskedDateTimeInput"; -export * from "./MaskedTimeInput"; +export * from "./DateMaskedInput"; From 5ee3c61bccf5c065a1087a9fbe66916b6c32775e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 8 Jan 2026 20:54:06 +0100 Subject: [PATCH 07/18] fix: more improvements --- playwright-tests/DateMaskedInput.spec.ts | 255 ++++++++++++++++++ .../Date/DateMaskedInput/DateMaskedInput.tsx | 58 +++- 2 files changed, 300 insertions(+), 13 deletions(-) diff --git a/playwright-tests/DateMaskedInput.spec.ts b/playwright-tests/DateMaskedInput.spec.ts index 4ef5b453..75fab026 100644 --- a/playwright-tests/DateMaskedInput.spec.ts +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -628,4 +628,259 @@ test.describe("DateMaskedInput Component", () => { 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 with Cmd+A and press Backspace + await input.press("Meta+a"); + 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("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}$/); + }); + }); }); diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx index a113db91..45bdb2fd 100644 --- a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx @@ -200,9 +200,12 @@ const DateMaskedInput: React.FC = ( const inputRef = useRef(null); const wrapperRef = useRef(null); const skipNextFocusRef = useRef(false); + // Track if click originated from our input area to prevent picker close/reopen flicker + const clickedInsideRef = useRef(false); const [pickerOpen, setPickerOpen] = useState(false); const [parseError, setParseError] = useState(null); - const [inputValue, setInputValue] = useState(""); + // Use undefined to mean "show displayValue", empty string means "user cleared" + const [inputValue, setInputValue] = useState(undefined); const datePickerLocale = useDatePickerLocale(); const requiredStyle = useRequiredStyle(required, readOnly); @@ -262,7 +265,8 @@ const DateMaskedInput: React.FC = ( ); }, [value, timezone, type, config]); - const currentInputValue = inputValue || displayValue; + // Use nullish coalescing: undefined = show displayValue, "" = show empty (user cleared) + const currentInputValue = inputValue ?? displayValue; // Convert value to picker format const pickerValue = useMemo(() => { @@ -311,7 +315,7 @@ const DateMaskedInput: React.FC = ( if (isEmpty) { onChange?.(null); - setInputValue(""); + setInputValue(undefined); setParseError(null); return; } @@ -320,7 +324,7 @@ const DateMaskedInput: React.FC = ( if (type === "time") { // Time values are stored as-is (HH:mm:ss) onChange?.(maskedValue); - setInputValue(""); + setInputValue(undefined); setParseError(null); } else { const internalValue = parseDisplayToInternal( @@ -330,7 +334,7 @@ const DateMaskedInput: React.FC = ( ); if (internalValue) { onChange?.(internalValue); - setInputValue(""); + setInputValue(undefined); setParseError(null); } else { setParseError(`Invalid ${type}`); @@ -342,7 +346,7 @@ const DateMaskedInput: React.FC = ( const autocompleted = autocompleteFn(maskedValue); if (autocompleted) { onChange?.(autocompleted.internalValue); - setInputValue(""); + setInputValue(undefined); setParseError(null); } else { setParseError(`Invalid ${type} format`); @@ -363,7 +367,7 @@ const DateMaskedInput: React.FC = ( const autocompleted = autocompleteFn(""); if (autocompleted) { onChange?.(autocompleted.internalValue); - setInputValue(""); + setInputValue(undefined); setParseError(null); } return; @@ -426,9 +430,23 @@ const DateMaskedInput: React.FC = ( const handleDoubleClick = useCallback( (e: React.MouseEvent) => { const input = e.target as HTMLInputElement; - commitValue(input.value); + const maskedValue = input.value; + + // On double-click, if no digits entered, autocomplete to current date/time + // (same behavior as Enter key) + if (!hasAnyDigits(maskedValue)) { + const autocompleted = autocompleteFn(""); + if (autocompleted) { + onChange?.(autocompleted.internalValue); + setInputValue(undefined); + setParseError(null); + } + return; + } + + commitValue(maskedValue); }, - [commitValue], + [commitValue, autocompleteFn, onChange], ); const handleBlur = useCallback( @@ -459,7 +477,7 @@ const DateMaskedInput: React.FC = ( e.stopPropagation(); e.preventDefault(); setPickerOpen(false); - setInputValue(""); + setInputValue(undefined); setParseError(null); if (onChange) { onChange(null); @@ -471,7 +489,7 @@ const DateMaskedInput: React.FC = ( (date: Dayjs | null) => { if (!date) { onChange?.(null); - setInputValue(""); + setInputValue(undefined); return; } @@ -480,7 +498,7 @@ const DateMaskedInput: React.FC = ( } else { onChange?.(date.format(config.internalFormat)); } - setInputValue(""); + setInputValue(undefined); setParseError(null); // For date-only mode, close picker after selection @@ -503,7 +521,7 @@ const DateMaskedInput: React.FC = ( } else { onChange?.(date.format(config.internalFormat)); } - setInputValue(""); + setInputValue(undefined); setParseError(null); setPickerOpen(false); skipNextFocusRef.current = true; @@ -517,12 +535,25 @@ const DateMaskedInput: React.FC = ( // Determine which icon to show const Icon = type === "time" ? ClockCircleOutlined : CalendarOutlined; + // Handle mousedown on our input area - prevents picker close/reopen flicker + const handleWrapperMouseDown = useCallback(() => { + clickedInsideRef.current = true; + // Reset after a short delay (after onOpenChange would have been called) + setTimeout(() => { + clickedInsideRef.current = false; + }, 100); + }, []); + // Render the appropriate picker based on type const renderPicker = () => { const commonProps = { open: pickerOpen, onOpenChange: (open: boolean) => { if (!open) { + // Don't close if the click was on our input area + if (clickedInsideRef.current) { + return; + } setPickerOpen(false); } }, @@ -562,6 +593,7 @@ const DateMaskedInput: React.FC = ( $required={requiredStyle} $disabled={readOnly} $hasError={!!parseError} + onMouseDown={handleWrapperMouseDown} onClick={() => { if (!readOnly) { setPickerOpen(true); From bd6222c047ef1bf81b05aa69782f72725e0803d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 8 Jan 2026 21:00:06 +0100 Subject: [PATCH 08/18] fix: more adjustmetns --- playwright-tests/DateMaskedInput.spec.ts | 27 +++++++++ .../Date/DateMaskedInput/DateMaskedInput.tsx | 55 ++++++++++++------- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/playwright-tests/DateMaskedInput.spec.ts b/playwright-tests/DateMaskedInput.spec.ts index 75fab026..a859c3c9 100644 --- a/playwright-tests/DateMaskedInput.spec.ts +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -758,6 +758,33 @@ test.describe("DateMaskedInput Component", () => { 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"); diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx index 45bdb2fd..7eb6d252 100644 --- a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx @@ -393,35 +393,50 @@ const DateMaskedInput: React.FC = ( } }, 50); } else if (e.key === "Tab") { - // Close picker before Tab navigates away e.preventDefault(); const input = e.target as HTMLInputElement; + const shiftKey = e.shiftKey; setPickerOpen(false); commitValue(input.value); - // Manually move focus to next/previous element based on Shift key - setTimeout(() => { - const focusableElements = - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; - const elements = Array.from( - document.querySelectorAll(focusableElements), + // Blur current input and move focus to next/previous focusable element + input.blur(); + + // Use requestAnimationFrame to ensure DOM has updated + 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) => { - // Exclude elements inside picker dropdown - return !el.closest(".ant-picker-dropdown"); + const htmlEl = el as HTMLElement; + // Must be visible and not inside picker dropdown + return ( + htmlEl.offsetParent !== null && + !el.closest(".ant-picker-dropdown") && + getComputedStyle(htmlEl).visibility !== "hidden" + ); }) as HTMLElement[]; - const index = elements.indexOf(input); - if (e.shiftKey) { - // Shift+Tab: move to previous element - if (index > 0) { - elements[index - 1].focus(); - } - } else { - // Tab: move to next element - if (index > -1 && index < elements.length - 1) { - elements[index + 1].focus(); + + 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(); } } - }, 0); + }); } }, [commitValue, onChange, autocompleteFn], From f6435ad3490b1ca9ed05fa0c4da64fb646c45e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 9 Jan 2026 08:20:31 +0100 Subject: [PATCH 09/18] fix: more tests --- package-lock.json | 66 ++++++++++-------------- playwright-tests/DateMaskedInput.spec.ts | 53 +++++++++++++++++++ 2 files changed, 80 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ccadab8..a7d3dc14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -289,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", @@ -3656,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", @@ -4716,7 +4714,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.9.1", @@ -4730,7 +4729,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.9.1", @@ -4743,7 +4743,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.9.1", @@ -4757,7 +4758,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.9.1", @@ -4771,7 +4773,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.9.1", @@ -4785,7 +4788,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.9.1", @@ -4799,7 +4803,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.9.1", @@ -4813,7 +4818,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.9.1", @@ -4827,7 +4833,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.9.1", @@ -4841,7 +4848,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.9.1", @@ -4855,7 +4863,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.9.1", @@ -4869,7 +4878,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.9.1", @@ -4883,7 +4893,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rushstack/node-core-library": { "version": "3.62.0", @@ -7280,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", @@ -7641,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" } @@ -7702,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": "*" } @@ -7830,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", @@ -7865,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", @@ -8300,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" }, @@ -9128,7 +9133,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -10238,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", @@ -10361,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" }, @@ -10385,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", @@ -11177,7 +11178,6 @@ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11278,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", @@ -11478,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", @@ -11541,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", @@ -11567,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" }, @@ -13017,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", @@ -15311,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" }, @@ -20902,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" }, @@ -20990,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" @@ -21839,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", @@ -22924,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", @@ -23601,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" @@ -23987,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/playwright-tests/DateMaskedInput.spec.ts b/playwright-tests/DateMaskedInput.spec.ts index a859c3c9..ec96e8d7 100644 --- a/playwright-tests/DateMaskedInput.spec.ts +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -910,4 +910,57 @@ test.describe("DateMaskedInput Component", () => { 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"); + }); + }); }); From 51ba92caa963850c490c5070f6d492125160f395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 9 Jan 2026 08:25:18 +0100 Subject: [PATCH 10/18] chore: add tests check --- .github/workflows/build_check.yaml | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 045961492d119a45da69d771a6936c3b866d12bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 9 Jan 2026 08:39:46 +0100 Subject: [PATCH 11/18] fix: improve code quality --- playwright-tests/DateInput.spec.ts | 43 +-- playwright-tests/DateMaskedInput.spec.ts | 13 +- .../Date/DateMaskedInput/DateMaskedInput.tsx | 77 +++-- .../MaskedDate.helpers.test.ts | 301 ++++++++++++++++++ .../DateMaskedInput/MaskedDate.helpers.ts | 6 +- 5 files changed, 384 insertions(+), 56 deletions(-) create mode 100644 src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.test.ts diff --git a/playwright-tests/DateInput.spec.ts b/playwright-tests/DateInput.spec.ts index 532236fd..65da2499 100644 --- a/playwright-tests/DateInput.spec.ts +++ b/playwright-tests/DateInput.spec.ts @@ -25,8 +25,11 @@ async function goToStory(page: Page, storyId: string) { state: "visible", timeout: 10000, }); - // Wait for antd components to fully render - await page.waitForTimeout(500); + // 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 @@ -89,11 +92,9 @@ test.describe("DateInput Component", () => { const clearBtn = page.locator(".ant-picker-clear"); await expect(clearBtn).toBeVisible({ timeout: 3000 }); await clearBtn.click(); - await page.waitForTimeout(300); - // Verify the input is now empty - const inputValue = await input.inputValue(); - expect(inputValue).toBe(""); + // 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:" }); @@ -160,9 +161,8 @@ test.describe("DateInput Component", () => { const picker = page.locator(".ant-picker").first(); await expect(picker).toBeVisible(); await picker.click(); - await page.waitForTimeout(300); - // Verify dropdown does NOT appear + // Verify dropdown does NOT appear (toBeHidden auto-waits) const dropdown = page.locator(".ant-picker-dropdown"); await expect(dropdown).toBeHidden(); }); @@ -171,11 +171,10 @@ test.describe("DateInput Component", () => { test.describe("Invalid Date Handling", () => { test("shows error state for invalid date value", async ({ page }) => { await goToStory(page, "components-widgets-date-dateinput--invalid-date"); - await page.waitForTimeout(500); - // Check for error styling (red border) - const picker = page.locator(".ant-picker").first(); - await expect(picker).toBeVisible(); + // 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") @@ -185,11 +184,10 @@ test.describe("DateInput Component", () => { test("displays error tooltip for invalid date", async ({ page }) => { await goToStory(page, "components-widgets-date-dateinput--invalid-date"); - await page.waitForTimeout(500); - // Error tooltip should be visible + // Error tooltip should be visible (auto-waits) const tooltip = page.locator(".ant-tooltip-inner"); - await expect(tooltip).toBeVisible({ timeout: 3000 }); + await expect(tooltip).toBeVisible({ timeout: 5000 }); const tooltipText = await tooltip.textContent(); expect(tooltipText).not.toBeNull(); @@ -432,19 +430,16 @@ test.describe("DateInput Component", () => { // 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-inner").filter({ hasText: /^15$/ }).first(); 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(); @@ -468,11 +463,9 @@ test.describe("DateInput Component", () => { const clearBtn = page.locator(".ant-picker-clear"); 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(""); + // Wait for input to be cleared + await expect(input).toHaveValue(""); // Verify debug shows empty value const debugElement = page.locator("pre").filter({ hasText: "String value:" }); @@ -508,7 +501,6 @@ test.describe("DateInput Component", () => { // 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"); @@ -516,9 +508,8 @@ test.describe("DateInput Component", () => { // Press Tab - should close picker and move focus out await page.keyboard.press("Tab"); - await page.waitForTimeout(300); - // Picker should be closed + // Picker should be closed (auto-waits) await expect(dropdown).toBeHidden({ timeout: 3000 }); // Focus should NOT be on the picker panel (should have moved past the component) @@ -535,7 +526,6 @@ test.describe("DateInput Component", () => { // 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"); @@ -544,7 +534,6 @@ test.describe("DateInput Component", () => { // Click on day 15 const day15 = page.locator(".ant-picker-cell-inner").filter({ hasText: /^15$/ }).first(); await day15.click(); - await page.waitForTimeout(300); // Picker should be closed after selecting day (date-only mode) await expect(dropdown).toBeHidden({ timeout: 3000 }); diff --git a/playwright-tests/DateMaskedInput.spec.ts b/playwright-tests/DateMaskedInput.spec.ts index ec96e8d7..88fadf12 100644 --- a/playwright-tests/DateMaskedInput.spec.ts +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -25,8 +25,11 @@ async function goToStory(page: Page, storyId: string) { state: "visible", timeout: 10000, }); - // Wait for antd components to fully render - await page.waitForTimeout(500); + // 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 @@ -90,11 +93,9 @@ test.describe("DateMaskedInput Component", () => { const clearBtn = page.locator(".ant-picker-clear").first(); await expect(clearBtn).toBeVisible({ timeout: 3000 }); await clearBtn.click(); - await page.waitForTimeout(300); - // Verify the input is now empty - const inputValue = await input.inputValue(); - expect(inputValue).toBe(""); + // 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:" }); diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx index 7eb6d252..9ea3a327 100644 --- a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx @@ -48,6 +48,7 @@ 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" : "" @@ -56,12 +57,14 @@ const InputWrapper = styled.div.attrs<{ $required?: React.CSSProperties; $disabled?: boolean; $hasError?: boolean; + $colorBgContainer?: string; }>` display: flex; align-items: center; width: 100%; position: relative; - background-color: ${(props) => props.$required?.backgroundColor || "#fff"}; + background-color: ${(props) => + props.$required?.backgroundColor || props.$colorBgContainer}; border-radius: 6px; `; @@ -71,6 +74,13 @@ const StyledInput = styled(IMaskInput)<{ $isEmpty?: boolean; $placeholderColor?: string; $textColor?: string; + $colorError?: string; + $colorBorder?: string; + $colorPrimary?: string; + $colorErrorBg?: string; + $colorPrimaryBg?: string; + $colorTextDisabled?: string; + $colorBgContainerDisabled?: string; }>` flex: 1; width: 100%; @@ -81,36 +91,43 @@ const StyledInput = styled(IMaskInput)<{ color: ${(props) => props.$isEmpty ? props.$placeholderColor : props.$textColor}; background-color: transparent; - border: 1px solid ${(props) => (props.$hasError ? "#ff4d4f" : "#d9d9d9")}; + 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 ? "#ff4d4f" : "#4096ff")}; + border-color: ${(props) => + props.$hasError ? props.$colorError : props.$colorPrimary}; box-shadow: 0 0 0 2px ${(props) => - props.$hasError ? "rgba(255, 77, 79, 0.1)" : "rgba(5, 145, 255, 0.1)"}; + props.$hasError ? props.$colorErrorBg : props.$colorPrimaryBg}; outline: none; } &:hover { - border-color: ${(props) => (props.$hasError ? "#ff4d4f" : "#4096ff")}; + border-color: ${(props) => + props.$hasError ? props.$colorError : props.$colorPrimary}; } &:disabled { - color: rgba(0, 0, 0, 0.25); - background-color: rgba(0, 0, 0, 0.04); + color: ${(props) => props.$colorTextDisabled}; + background-color: ${(props) => props.$colorBgContainerDisabled}; cursor: not-allowed; } `; -const SuffixIcon = styled.span<{ $allowClear?: boolean }>` +const SuffixIcon = styled.span<{ + $allowClear?: boolean; + $colorTextQuaternary?: string; + $colorTextSecondary?: string; +}>` position: absolute; right: 11px; top: 50%; transform: translateY(-50%); - color: rgba(0, 0, 0, 0.25); + color: ${(props) => props.$colorTextQuaternary}; cursor: ${(props) => (props.$allowClear ? "pointer" : "default")}; transition: color 0.2s, @@ -122,7 +139,9 @@ const SuffixIcon = styled.span<{ $allowClear?: boolean }>` &:hover { color: ${(props) => - props.$allowClear ? "rgba(0, 0, 0, 0.45)" : "rgba(0, 0, 0, 0.25)"}; + props.$allowClear + ? props.$colorTextSecondary + : props.$colorTextQuaternary}; } `; @@ -331,13 +350,14 @@ const DateMaskedInput: React.FC = ( maskedValue, config.displayFormat, config.internalFormat, + timezone, ); if (internalValue) { onChange?.(internalValue); setInputValue(undefined); setParseError(null); } else { - setParseError(`Invalid ${type}`); + setParseError(`Invalid ${type} format`); } } return; @@ -488,16 +508,17 @@ const DateMaskedInput: React.FC = ( } }, [readOnly]); - const handleClear = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - setPickerOpen(false); - setInputValue(undefined); - setParseError(null); - if (onChange) { - onChange(null); - } - }; + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setPickerOpen(false); + setInputValue(undefined); + setParseError(null); + onChange?.(null); + }, + [onChange], + ); // Handle picker change (when user selects from dropdown) const handlePickerChange = useCallback( @@ -600,7 +621,7 @@ const DateMaskedInput: React.FC = (
@@ -608,6 +629,7 @@ const DateMaskedInput: React.FC = ( $required={requiredStyle} $disabled={readOnly} $hasError={!!parseError} + $colorBgContainer={token.colorBgContainer} onMouseDown={handleWrapperMouseDown} onClick={() => { if (!readOnly) { @@ -635,6 +657,13 @@ const DateMaskedInput: React.FC = ( $isEmpty={!value && !hasAnyDigits(currentInputValue)} $placeholderColor={token.colorTextPlaceholder} $textColor={token.colorText} + $colorError={token.colorError} + $colorBorder={token.colorBorder} + $colorPrimary={token.colorPrimary} + $colorErrorBg={token.colorErrorBg} + $colorPrimaryBg={token.colorPrimaryBg} + $colorTextDisabled={token.colorTextDisabled} + $colorBgContainerDisabled={token.colorBgContainerDisabled} placeholder={effectivePlaceholder} style={{ paddingRight: 30 }} /> @@ -642,6 +671,8 @@ const DateMaskedInput: React.FC = ( { setPickerOpen(true); inputRef.current?.focus(); @@ -655,6 +686,8 @@ const DateMaskedInput: React.FC = ( { 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 index 452fdda7..903759b7 100644 --- a/src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.ts +++ b/src/components/widgets/Date/DateMaskedInput/MaskedDate.helpers.ts @@ -214,11 +214,15 @@ export const parseDisplayToInternal = ( value: string, displayFormat: string, internalFormat: string, + timezone?: string, ): string | null => { if (!value || value.includes("_")) return null; try { - const parsed = dayjs(value, displayFormat); + // 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; From b9e08edd11a89fb79d1541ef8332b1774492d51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 9 Jan 2026 08:45:50 +0100 Subject: [PATCH 12/18] fix: add locale cases --- playwright-tests/DateInput.spec.ts | 96 ++++++++++++++++ playwright-tests/DateMaskedInput.spec.ts | 95 ++++++++++++++++ .../Date/DateInput/DateInput.stories.tsx | 104 ++++++++++++++++++ .../DateMaskedInput.stories.tsx | 78 +++++++++++++ 4 files changed, 373 insertions(+) diff --git a/playwright-tests/DateInput.spec.ts b/playwright-tests/DateInput.spec.ts index 65da2499..4e523087 100644 --- a/playwright-tests/DateInput.spec.ts +++ b/playwright-tests/DateInput.spec.ts @@ -615,4 +615,100 @@ test.describe("DateInput Component", () => { 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/); + }); + }); }); diff --git a/playwright-tests/DateMaskedInput.spec.ts b/playwright-tests/DateMaskedInput.spec.ts index 88fadf12..a2e14b56 100644 --- a/playwright-tests/DateMaskedInput.spec.ts +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -964,4 +964,99 @@ test.describe("DateMaskedInput Component", () => { 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/); + }); + }); }); 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/DateMaskedInput/DateMaskedInput.stories.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.stories.tsx index e3f75837..fbed1336 100644 --- a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.stories.tsx +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.stories.tsx @@ -3,6 +3,7 @@ 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; @@ -545,3 +546,80 @@ Test keyboard and mouse behaviors: }, }, }; + +// ============================================ +// 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)"; From b3d2cc56756a425f0fae0ebd89885679b782a32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 9 Jan 2026 08:50:11 +0100 Subject: [PATCH 13/18] fix: revert old behaviour in dateinput --- playwright-tests/DateInput.spec.ts | 25 ------------ .../DateInput/hooks/useDatePickerHandlers.ts | 39 ------------------- 2 files changed, 64 deletions(-) diff --git a/playwright-tests/DateInput.spec.ts b/playwright-tests/DateInput.spec.ts index 4e523087..d0c5c088 100644 --- a/playwright-tests/DateInput.spec.ts +++ b/playwright-tests/DateInput.spec.ts @@ -495,31 +495,6 @@ test.describe("DateInput Component", () => { await expect(dropdown).toBeHidden({ timeout: 3000 }); }); - test("closes picker and moves focus on Tab key", async ({ page }) => { - await goToStory(page, "components-widgets-date-dateinput--basic"); - - // Focus the input which opens the picker - const input = await getInput(page); - await input.click(); - - // 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"); - - // Picker should be closed (auto-waits) - 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-dateinput--date-only"); diff --git a/src/components/widgets/Date/DateInput/hooks/useDatePickerHandlers.ts b/src/components/widgets/Date/DateInput/hooks/useDatePickerHandlers.ts index af4486a0..f36b2656 100644 --- a/src/components/widgets/Date/DateInput/hooks/useDatePickerHandlers.ts +++ b/src/components/widgets/Date/DateInput/hooks/useDatePickerHandlers.ts @@ -91,45 +91,6 @@ export const useDatePickerHandlers = ({ elements[index + 1].focus(); } }, 100); - } else if (e.key === "Tab") { - // Close picker before Tab navigates away - e.preventDefault(); - - const input = e.currentTarget; - if (input.value !== "") { - const dayJsDate = dayjs( - input.value, - DatePickerConfig[mode].dateDisplayFormat, - ).format(DatePickerConfig[mode].dateInternalFormat); - onChange?.(dayJsDate); - } - - // Blur input to close the picker - input.blur(); - - // Manually move focus to next/previous element based on Shift key - setTimeout(() => { - const focusableElements = - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; - const elements = Array.from( - document.querySelectorAll(focusableElements), - ).filter((el) => { - // Exclude elements inside picker dropdown - return !el.closest(".ant-picker-dropdown"); - }) as HTMLElement[]; - const index = elements.indexOf(input); - if (e.shiftKey) { - // Shift+Tab: move to previous element - if (index > 0) { - elements[index - 1].focus(); - } - } else { - // Tab: move to next element - if (index > -1 && index < elements.length - 1) { - elements[index + 1].focus(); - } - } - }, 0); } }, [showTime, mode, onChange], From 6f0860d6f5ad44e89fd620faa861a62951d476a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 9 Jan 2026 08:54:07 +0100 Subject: [PATCH 14/18] fix: adjust meta a mac keyboard issue --- playwright-tests/DateMaskedInput.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/playwright-tests/DateMaskedInput.spec.ts b/playwright-tests/DateMaskedInput.spec.ts index a2e14b56..cdf61e33 100644 --- a/playwright-tests/DateMaskedInput.spec.ts +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -744,8 +744,9 @@ test.describe("DateMaskedInput Component", () => { await input.click(); await page.waitForTimeout(200); - // Select all with Cmd+A and press Backspace - await input.press("Meta+a"); + // 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); From 49b7764795990686beb60dd1cfec9c75d201c676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 9 Jan 2026 09:10:24 +0100 Subject: [PATCH 15/18] fix: tests and footer ok button visibility issues --- playwright-tests/DateInput.spec.ts | 32 ++- playwright-tests/DateMaskedInput.spec.ts | 21 ++ .../widgets/Date/DateInput/DateInput.tsx | 59 +++--- .../Date/DateMaskedInput/DateMaskedInput.tsx | 195 ++++++++++-------- 4 files changed, 188 insertions(+), 119 deletions(-) diff --git a/playwright-tests/DateInput.spec.ts b/playwright-tests/DateInput.spec.ts index d0c5c088..b88dc520 100644 --- a/playwright-tests/DateInput.spec.ts +++ b/playwright-tests/DateInput.spec.ts @@ -420,12 +420,13 @@ test.describe("DateInput Component", () => { }); test("selects date from calendar", async ({ page }) => { - await goToStory(page, "components-widgets-date-dateinput--basic"); + // 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 14:30:00"); + expect(initialValue).toBe("10/03/2024"); // Open calendar const picker = page.locator(".ant-picker").first(); @@ -436,12 +437,11 @@ test.describe("DateInput Component", () => { await expect(day15).toBeVisible({ timeout: 3000 }); await day15.click(); - // 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(); + // 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 (keeping time) + // Value should have changed to the 15th const newDisplayValue = await input.inputValue(); expect(newDisplayValue).toContain("15/03/2024"); @@ -686,4 +686,22 @@ test.describe("DateInput Component", () => { 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 index cdf61e33..71c88289 100644 --- a/playwright-tests/DateMaskedInput.spec.ts +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -1060,4 +1060,25 @@ test.describe("DateMaskedInput Component", () => { 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"); + }); + }); }); 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.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx index 9ea3a327..c8acb16a 100644 --- a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx @@ -12,7 +12,14 @@ import { CloseCircleFilled, } from "@ant-design/icons"; import dayjs, { Dayjs } from "dayjs"; -import styled from "styled-components"; +import styled, { createGlobalStyle } from "styled-components"; + +// Force show the picker footer for DateMaskedInput, overriding any global CSS +const DateMaskedInputPickerStyles = createGlobalStyle` + .date-masked-input-picker-dropdown .ant-picker-footer { + display: block !important; + } +`; import { DateMaskedInputProps, DateMaskedInputType, @@ -605,7 +612,13 @@ const DateMaskedInput: React.FC = ( }; if (type === "time") { - return ; + return ( + + ); } return ( @@ -613,98 +626,102 @@ const DateMaskedInput: React.FC = ( {...commonProps} showTime={type === "datetime"} onOk={type === "datetime" ? handleOk : undefined} + popupClassName="date-masked-input-picker-dropdown" /> ); }; return ( - -
- { - if (!readOnly) { - setPickerOpen(true); - inputRef.current?.focus(); - } - }} - > - - - {!readOnly && ( - { - setPickerOpen(true); - inputRef.current?.focus(); - }} - style={value ? undefined : { opacity: 1 }} - > - - - )} - {!readOnly && value && ( - { - e.preventDefault(); - e.stopPropagation(); - }} - > - - - )} - - - - {/* Hidden picker - only used for its dropdown */} - {renderPicker()} -
-
+ <> + + +
+ { + if (!readOnly) { + setPickerOpen(true); + inputRef.current?.focus(); + } + }} + > + + + {!readOnly && ( + { + setPickerOpen(true); + inputRef.current?.focus(); + }} + style={value ? undefined : { opacity: 1 }} + > + + + )} + {!readOnly && value && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + )} + + + + {/* Hidden picker - only used for its dropdown */} + {renderPicker()} +
+
+ ); }; From 9de878dff210ed60d470afadadd0d2eb8cc79725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 9 Jan 2026 12:32:34 +0100 Subject: [PATCH 16/18] fix: picker container placement bug --- src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx index c8acb16a..e01102d9 100644 --- a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx @@ -603,7 +603,7 @@ const DateMaskedInput: React.FC = ( value: pickerValue, onChange: handlePickerChange, locale: datePickerLocale, - getPopupContainer: () => wrapperRef.current || document.body, + getPopupContainer: () => document.body, showNow: false, showToday: false, allowClear: false, From c7b4fee8851c74545e03e0dcf496ae2f7c992ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Mon, 12 Jan 2026 12:15:29 +0100 Subject: [PATCH 17/18] feat: two way sync and other improvements --- playwright-tests/DateMaskedInput.spec.ts | 341 ++++++++++- .../DateMaskedInput/DateMaskedInput.styles.ts | 159 ++++++ .../Date/DateMaskedInput/DateMaskedInput.tsx | 538 +++++++++--------- 3 files changed, 753 insertions(+), 285 deletions(-) create mode 100644 src/components/widgets/Date/DateMaskedInput/DateMaskedInput.styles.ts diff --git a/playwright-tests/DateMaskedInput.spec.ts b/playwright-tests/DateMaskedInput.spec.ts index 71c88289..cefd64c3 100644 --- a/playwright-tests/DateMaskedInput.spec.ts +++ b/playwright-tests/DateMaskedInput.spec.ts @@ -437,7 +437,7 @@ test.describe("DateMaskedInput Component", () => { await page.waitForTimeout(300); // Click on day 15 in the calendar - const day15 = page.locator(".ant-picker-cell-inner").filter({ hasText: /^15$/ }).first(); + 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); @@ -545,7 +545,7 @@ test.describe("DateMaskedInput Component", () => { await expect(dropdown).toBeVisible({ timeout: 3000 }); // Click on day 15 - const day15 = page.locator(".ant-picker-cell-inner").filter({ hasText: /^15$/ }).first(); + const day15 = page.locator(".ant-picker-cell-in-view .ant-picker-cell-inner").filter({ hasText: /^15$/ }); await day15.click(); await page.waitForTimeout(300); @@ -1081,4 +1081,341 @@ test.describe("DateMaskedInput Component", () => { 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/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 index e01102d9..a009bb23 100644 --- a/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx +++ b/src/components/widgets/Date/DateMaskedInput/DateMaskedInput.tsx @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { IMaskInput } from "react-imask"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DatePicker as AntDatePicker, TimePicker as AntTimePicker, @@ -12,14 +11,6 @@ import { CloseCircleFilled, } from "@ant-design/icons"; import dayjs, { Dayjs } from "dayjs"; -import styled, { createGlobalStyle } from "styled-components"; - -// Force show the picker footer for DateMaskedInput, overriding any global CSS -const DateMaskedInputPickerStyles = createGlobalStyle` - .date-masked-input-picker-dropdown .ant-picker-footer { - display: block !important; - } -`; import { DateMaskedInputProps, DateMaskedInputType, @@ -38,9 +29,17 @@ import { } from "./MaskedDate.helpers"; import { useRequiredStyle } from "@/hooks/useRequiredStyle"; import { useDatePickerLocale } from "../DateInput/hooks/useDatePickerLocale"; - -// Get config based on type -const getConfig = (type: DateMaskedInputType) => { +import { + DateMaskedInputPickerStyles, + InputWrapper, + StyledInput, + ClearIcon, + CalendarIcon, + InputContainer, + HiddenPickerWrapper, +} from "./DateMaskedInput.styles"; + +function getConfig(type: DateMaskedInputType) { switch (type) { case "date": return MaskedDateConfig; @@ -49,166 +48,11 @@ const getConfig = (type: DateMaskedInputType) => { case "time": return MaskedTimeConfig; } -}; - -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; -`; - -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}; - } -`; - -const ClearIcon = styled(SuffixIcon)` - opacity: 0; -`; - -const CalendarIcon = styled(SuffixIcon)` - opacity: 1; -`; - -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; - } -`; - -// Hidden picker container - only shows the dropdown, input is invisible -const HiddenPickerWrapper = styled.div` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - overflow: hidden; - - /* Hide the picker input completely - use visibility:hidden instead of opacity - to ensure it doesn't interfere with selectors while still rendering the dropdown */ - > .ant-picker { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - visibility: hidden; - pointer-events: none; - } - - /* Ensure the dropdown (rendered in portal) remains visible */ - .ant-picker-dropdown { - visibility: visible; - } -`; +} -const DateMaskedInput: React.FC = ( +const DateMaskedInputComponent = memo(function DateMaskedInput( props: DateMaskedInputProps, -) => { +): React.ReactElement { const { type, value, @@ -226,20 +70,17 @@ const DateMaskedInput: React.FC = ( const inputRef = useRef(null); const wrapperRef = useRef(null); const skipNextFocusRef = useRef(false); - // Track if click originated from our input area to prevent picker close/reopen flicker const clickedInsideRef = useRef(false); const [pickerOpen, setPickerOpen] = useState(false); const [parseError, setParseError] = useState(null); - // Use undefined to mean "show displayValue", empty string means "user cleared" const [inputValue, setInputValue] = useState(undefined); const datePickerLocale = useDatePickerLocale(); const requiredStyle = useRequiredStyle(required, readOnly); const effectivePlaceholder = placeholder || config.placeholder; - // Check if the value prop is valid (for detecting invalid incoming values) const isValuePropValid = useMemo(() => { - if (!value) return true; // Empty is valid (no error state) + if (!value) return true; if (type === "time") { try { @@ -260,17 +101,14 @@ const DateMaskedInput: React.FC = ( } }, [value, timezone, type, config]); - // Set error state when value prop is invalid (like DateInput does) useEffect(() => { if (value && !isValuePropValid) { setParseError("Invalid date format"); } else if (isValuePropValid && !inputValue) { - // Only clear error when value becomes valid AND there's no pending user input setParseError(null); } }, [value, isValuePropValid, inputValue]); - // Convert internal value to display format const displayValue = useMemo(() => { if (!value) return ""; @@ -291,11 +129,34 @@ const DateMaskedInput: React.FC = ( ); }, [value, timezone, type, config]); - // Use nullish coalescing: undefined = show displayValue, "" = show empty (user cleared) const currentInputValue = inputValue ?? displayValue; - // Convert value to picker format 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") { @@ -309,14 +170,18 @@ const DateMaskedInput: React.FC = ( } catch { return undefined; } - }, [value, timezone, type, config]); + }, [value, inputValue, timezone, type, config]); + + const clearError = useCallback(() => { + setInputValue(undefined); + setParseError(null); + }, []); const handleAccept = useCallback((maskedValue: string) => { setInputValue(maskedValue); setParseError(null); }, []); - // Autocomplete based on type const autocompleteFn = useCallback( (maskedValue: string) => { switch (type) { @@ -333,7 +198,6 @@ const DateMaskedInput: React.FC = ( const commitValue = useCallback( (maskedValue: string) => { - // Treat empty, no digits, or placeholder-only values as clearing the field const isEmpty = !maskedValue || !hasAnyDigits(maskedValue) || @@ -341,17 +205,14 @@ const DateMaskedInput: React.FC = ( if (isEmpty) { onChange?.(null); - setInputValue(undefined); - setParseError(null); + clearError(); return; } if (isCompleteValue(maskedValue, effectivePlaceholder)) { if (type === "time") { - // Time values are stored as-is (HH:mm:ss) onChange?.(maskedValue); - setInputValue(undefined); - setParseError(null); + clearError(); } else { const internalValue = parseDisplayToInternal( maskedValue, @@ -361,8 +222,7 @@ const DateMaskedInput: React.FC = ( ); if (internalValue) { onChange?.(internalValue); - setInputValue(undefined); - setParseError(null); + clearError(); } else { setParseError(`Invalid ${type} format`); } @@ -373,15 +233,30 @@ const DateMaskedInput: React.FC = ( const autocompleted = autocompleteFn(maskedValue); if (autocompleted) { onChange?.(autocompleted.internalValue); - setInputValue(undefined); - setParseError(null); + clearError(); } else { setParseError(`Invalid ${type} format`); } }, - [onChange, effectivePlaceholder, type, config, autocompleteFn], + [ + 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") { @@ -389,14 +264,8 @@ const DateMaskedInput: React.FC = ( const input = e.target as HTMLInputElement; const maskedValue = input.value; - // On Enter, if no digits entered, autocomplete to current date/time if (!hasAnyDigits(maskedValue)) { - const autocompleted = autocompleteFn(""); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(undefined); - setParseError(null); - } + autocompleteEmpty(); return; } @@ -426,10 +295,8 @@ const DateMaskedInput: React.FC = ( setPickerOpen(false); commitValue(input.value); - // Blur current input and move focus to next/previous focusable element input.blur(); - // Use requestAnimationFrame to ensure DOM has updated requestAnimationFrame(() => { const focusableSelector = 'input:not([disabled]):not([tabindex="-1"]), ' + @@ -442,7 +309,6 @@ const DateMaskedInput: React.FC = ( document.querySelectorAll(focusableSelector), ).filter((el) => { const htmlEl = el as HTMLElement; - // Must be visible and not inside picker dropdown return ( htmlEl.offsetParent !== null && !el.closest(".ant-picker-dropdown") && @@ -466,7 +332,7 @@ const DateMaskedInput: React.FC = ( }); } }, - [commitValue, onChange, autocompleteFn], + [commitValue, autocompleteEmpty], ); const handleDoubleClick = useCallback( @@ -474,27 +340,18 @@ const DateMaskedInput: React.FC = ( const input = e.target as HTMLInputElement; const maskedValue = input.value; - // On double-click, if no digits entered, autocomplete to current date/time - // (same behavior as Enter key) if (!hasAnyDigits(maskedValue)) { - const autocompleted = autocompleteFn(""); - if (autocompleted) { - onChange?.(autocompleted.internalValue); - setInputValue(undefined); - setParseError(null); - } + autocompleteEmpty(); return; } commitValue(maskedValue); }, - [commitValue, autocompleteFn, onChange], + [commitValue, autocompleteEmpty], ); const handleBlur = useCallback( (e: React.FocusEvent) => { - // Check if focus is moving to an element inside the picker dropdown - // If so, don't close the picker (user is interacting with it) const relatedTarget = e.relatedTarget as HTMLElement | null; if (relatedTarget?.closest(".ant-picker-dropdown")) { return; @@ -520,101 +377,226 @@ const DateMaskedInput: React.FC = ( e.stopPropagation(); e.preventDefault(); setPickerOpen(false); - setInputValue(undefined); - setParseError(null); + clearError(); onChange?.(null); }, - [onChange], + [onChange, clearError], ); - // Handle picker change (when user selects from dropdown) const handlePickerChange = useCallback( (date: Dayjs | null) => { if (!date) { onChange?.(null); - setInputValue(undefined); + clearError(); return; } - if (type === "time") { - onChange?.(date.format("HH:mm:ss")); - } else { - onChange?.(date.format(config.internalFormat)); - } - setInputValue(undefined); - setParseError(null); - - // For date-only mode, close picker after selection if (type === "date") { + onChange?.(date.format(config.internalFormat)); + clearError(); setPickerOpen(false); skipNextFocusRef.current = true; - setTimeout(() => { - inputRef.current?.focus(); - }, 50); + setTimeout(() => inputRef.current?.focus(), 50); } }, - [type, config, onChange], + [type, config, onChange, clearError], ); - // Handle OK button click in datetime/time mode - const handleOk = useCallback( + const updateInputFromDate = useCallback( (date: Dayjs) => { - if (type === "time") { - onChange?.(date.format("HH:mm:ss")); - } else { - onChange?.(date.format(config.internalFormat)); - } - setInputValue(undefined); + 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); + setTimeout(() => inputRef.current?.focus(), 50); }, - [type, config, onChange], + [type, config, onChange, clearError], ); - // Determine which icon to show const Icon = type === "time" ? ClockCircleOutlined : CalendarOutlined; - // Handle mousedown on our input area - prevents picker close/reopen flicker const handleWrapperMouseDown = useCallback(() => { clickedInsideRef.current = true; - // Reset after a short delay (after onOpenChange would have been called) setTimeout(() => { clickedInsideRef.current = false; }, 100); }, []); - // Render the appropriate picker based on type + 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: (open: boolean) => { - if (!open) { - // Don't close if the click was on our input area - if (clickedInsideRef.current) { - return; - } - setPickerOpen(false); - } - }, + onOpenChange: handleOpenChange, value: pickerValue, onChange: handlePickerChange, locale: datePickerLocale, - getPopupContainer: () => document.body, + getPopupContainer, showNow: false, showToday: false, allowClear: false, inputReadOnly: true, - style: { width: "100%", height: "100%" }, + style: pickerStyle, }; if (type === "time") { return ( @@ -625,6 +607,8 @@ const DateMaskedInput: React.FC = ( @@ -640,19 +624,14 @@ const DateMaskedInput: React.FC = ( color={token.colorError} placement="topLeft" > -
+
{ - if (!readOnly) { - setPickerOpen(true); - inputRef.current?.focus(); - } - }} + onClick={handleWrapperClick} > = ( $colorTextDisabled={token.colorTextDisabled} $colorBgContainerDisabled={token.colorBgContainerDisabled} placeholder={effectivePlaceholder} - style={{ paddingRight: 30 }} + style={inputPaddingStyle} /> {!readOnly && ( = ( $allowClear={false} $colorTextQuaternary={token.colorTextQuaternary} $colorTextSecondary={token.colorTextSecondary} - onClick={() => { - setPickerOpen(true); - inputRef.current?.focus(); - }} - style={value ? undefined : { opacity: 1 }} + onClick={handleIconClick} + style={value ? undefined : opacityStyle} > - + )} {!readOnly && value && ( @@ -706,25 +682,21 @@ const DateMaskedInput: React.FC = ( $colorTextSecondary={token.colorTextSecondary} data-testid="clear-button" onClick={handleClear} - onMouseDown={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} + onMouseDown={handleClearMouseDown} > - + )} - {/* Hidden picker - only used for its dropdown */} {renderPicker()}
); -}; +}); -DateMaskedInput.displayName = "DateMaskedInput"; +DateMaskedInputComponent.displayName = "DateMaskedInput"; -export { DateMaskedInput }; +export { DateMaskedInputComponent as DateMaskedInput }; From 5fedde54bc323fc32018bcb1e8b19d26ffe69083 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 13 Jan 2026 12:31:35 +0000 Subject: [PATCH 18/18] chore(release): 1.20.0 [skip ci] # [1.20.0](https://github.com/gisce/react-formiga-components/compare/v1.19.1...v1.20.0) (2026-01-13) ### Bug Fixes * add locale cases ([b9e08ed](https://github.com/gisce/react-formiga-components/commit/b9e08edd11a89fb79d1541ef8332b1774492d51b)) * adjust meta a mac keyboard issue ([6f0860d](https://github.com/gisce/react-formiga-components/commit/6f0860d6f5ad44e89fd620faa861a62951d476a2)) * autocomplete to current date on enter with empty input, integrated icon style ([ffff844](https://github.com/gisce/react-formiga-components/commit/ffff8442de65c5c78406fe8a2933024dff67f8e9)), closes [gisce/webclient#2291](https://github.com/gisce/webclient/issues/2291) * improve code quality ([0459614](https://github.com/gisce/react-formiga-components/commit/045961492d119a45da69d771a6936c3b866d12bb)) * more adjustmetns ([bd6222c](https://github.com/gisce/react-formiga-components/commit/bd6222c047ef1bf81b05aa69782f72725e0803d0)) * more improvements ([5ee3c61](https://github.com/gisce/react-formiga-components/commit/5ee3c61bccf5c065a1087a9fbe66916b6c32775e)) * more tests ([f6435ad](https://github.com/gisce/react-formiga-components/commit/f6435ad3490b1ca9ed05fa0c4da64fb646c45e6b)) * picker container placement bug ([9de878d](https://github.com/gisce/react-formiga-components/commit/9de878dff210ed60d470afadadd0d2eb8cc79725)) * revert old behaviour in dateinput ([b3d2cc5](https://github.com/gisce/react-formiga-components/commit/b3d2cc56756a425f0fae0ebd89885679b782a32a)) * tests and footer ok button visibility issues ([49b7764](https://github.com/gisce/react-formiga-components/commit/49b7764795990686beb60dd1cfec9c75d201c676)) ### Features * add double-click handler and improved storybook docs ([351f8a4](https://github.com/gisce/react-formiga-components/commit/351f8a429b2293dc3113e0b7cac91731fe2380ce)), closes [gisce/webclient#2291](https://github.com/gisce/webclient/issues/2291) [gisce/webclient#2291](https://github.com/gisce/webclient/issues/2291) * add masked input components for date/time widgets ([179f43b](https://github.com/gisce/react-formiga-components/commit/179f43b2001a0a545c35e02213194aa385688e65)), closes [gisce/webclient#2291](https://github.com/gisce/webclient/issues/2291) * new approach for date with masked inputs ([18f2045](https://github.com/gisce/react-formiga-components/commit/18f2045eb30fdbdf3e5130bc9b589fd94c2e1e43)) * tests and date masked input ([abde828](https://github.com/gisce/react-formiga-components/commit/abde82811d4fd31ba9cc2c12b612bff77672ac0c)) * two way sync and other improvements ([c7b4fee](https://github.com/gisce/react-formiga-components/commit/c7b4fee8851c74545e03e0dcf496ae2f7c992ae7)) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a7d3dc14..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", diff --git a/package.json b/package.json index 8bdce35a..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": {