diff --git a/.storybook/manager.ts b/.storybook/manager.ts index 5ae11ce7..338a7927 100644 --- a/.storybook/manager.ts +++ b/.storybook/manager.ts @@ -5,15 +5,15 @@ addons.setConfig({ theme: create({ base: "light", brandTitle: - '

frappe-ui-react

(v1.0.0-beta.3)', // update version as per package.json + '

frappe-ui-react

(v1.0.2)', // update version as per package.json brandUrl: undefined, // disables link on the title brandImage: undefined, - // Sidebar/Toolbar active state color - barSelectedColor: 'rgb(153, 153, 153)', - + // Sidebar/Toolbar active state color + barSelectedColor: 'rgb(153, 153, 153)', + // Primary accent color for buttons, links, focus states - colorPrimary: 'rgb(153, 153, 153)', + colorPrimary: 'rgb(153, 153, 153)', colorSecondary: 'rgb(153, 153, 153)', fontBase: '"Inter", sans-serif', }), diff --git a/package-lock.json b/package-lock.json index 3ef9a031..8d3cb015 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "frappe-ui-react", - "version": "1.0.0-beta.3", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frappe-ui-react", - "version": "1.0.0-beta.3", + "version": "1.0.1", "workspaces": [ "packages/*" ], @@ -51,7 +51,7 @@ "playwright": "^1.54.1", "prettier": "^2.8.8", "rimraf": "^5.0.5", - "storybook": "^9.1.3", + "storybook": "^9.1.17", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "vite": "^7.1.11", @@ -132,6 +132,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1939,6 +1940,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1962,6 +1964,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5298,6 +5301,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5577,6 +5581,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5587,6 +5592,7 @@ "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5650,6 +5656,7 @@ "integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.0", @@ -5690,6 +5697,7 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -5920,6 +5928,34 @@ "dev": true, "license": "ISC" }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", "cpu": [ @@ -5932,6 +5968,233 @@ "darwin" ] }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -5959,6 +6222,7 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -6096,6 +6360,7 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -6154,6 +6419,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6686,6 +6952,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -7810,6 +8077,7 @@ "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7851,6 +8119,7 @@ "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.3.4" }, @@ -7887,6 +8156,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9654,6 +9924,7 @@ "version": "30.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -10583,6 +10854,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -12421,6 +12693,7 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -12539,6 +12812,7 @@ "node_modules/quill": { "version": "2.0.3", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "eventemitter3": "^5.0.1", "lodash-es": "^4.17.21", @@ -12552,6 +12826,7 @@ "node_modules/quill-delta": { "version": "5.1.0", "license": "MIT", + "peer": true, "dependencies": { "fast-diff": "^1.3.0", "lodash.clonedeep": "^4.5.0", @@ -12630,6 +12905,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12670,6 +12946,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13097,6 +13374,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13628,9 +13906,12 @@ } }, "node_modules/storybook": { - "version": "9.1.10", + "version": "9.1.17", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.17.tgz", + "integrity": "sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", @@ -14438,6 +14719,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14724,6 +15006,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14865,6 +15148,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -15410,7 +15694,7 @@ }, "packages/frappe-ui-react": { "name": "@rtcamp/frappe-ui-react", - "version": "1.0.0-beta.3", + "version": "1.0.1", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.27.13", diff --git a/package.json b/package.json index e7d54d17..a1c1af2e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frappe-ui-react", "private": true, - "version": "1.0.0-beta.3", + "version": "1.0.2", "type": "module", "workspaces": [ "packages/*" @@ -64,7 +64,7 @@ "playwright": "^1.54.1", "prettier": "^2.8.8", "rimraf": "^5.0.5", - "storybook": "^9.1.3", + "storybook": "^9.1.17", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "vite": "^7.1.11", diff --git a/packages/frappe-ui-react/README.md b/packages/frappe-ui-react/README.md index 6082ddb2..fdc5a4de 100644 --- a/packages/frappe-ui-react/README.md +++ b/packages/frappe-ui-react/README.md @@ -1,49 +1,93 @@ -# Frappe UI React +Frappe UI React is a modern component library designed for building frontend applications in React, specifically tailored for the [Frappe Framework](https://frappe.io/framework). It is inspired by the original [frappe-ui](https://github.com/frappe/frappe-ui), which is created in Vue, offering a similar components and aesthetic in React. However, we are not limited to a one-to-one port and also provide a growing collection of custom-built components to give developers more power and flexibility. -Frappe UI React is a component library designed for rapid UI development using React 19 and Tailwind 4. It is inspired by the original [frappe-ui](https://github.com/frappe/frappe-ui), offering a similar aesthetic for a consistent user experience. However, we are not limited to a one-to-one port and also provide a growing collection of custom-built components to give developers more power and flexibility. +## Prerequisites -## Under the Hood +- Node.js v20 +- TailwindCSS v4 -- [TailwindCSS](https://github.com/tailwindlabs/tailwindcss): Utility first CSS Framework to build design system based UI. -- [Headless UI](https://github.com/tailwindlabs/headlessui): Unstyled and accessible UI components. -- [Radix UI](https://github.com/radix-ui/themes): Unstyled and accessible UI components. -- [TipTap](https://github.com/ueberdosis/tiptap): ProseMirror based rich-text editor with a Vue API. -- [dayjs](https://github.com/iamkun/dayjs): Minimal javascript library for working with dates. +## Usage -## Links +You can set up `frappe-ui-react` in your existing Frappe app with the following simple steps. You can also spin up a new project instantly using the [frappe-ui-react-starter](https://github.com/rtCamp/frappe-ui-react-starter) template. -- [Documentation](https://frappeui.com) -- [Community](https://github.com/rtCamp/frappe-ui-react/discussions) +### Step 1: Installation -## Prerequisites +Install the package using npm. -- Node.js v20 -- TailwindCSS v4 +```bash +npm install @rtcamp/frappe-ui-react +``` -## Usage +### Step 2: Configuration + +Import the theme CSS directly into your project (e.g., in `index.css`) and provide the source of the frappe-ui-react package so that it picks the styles automatically. -```sh -npm install @frappe-ui-react/frappe-ui-react -# or -yarn add @frappe-ui-react/frappe-ui-react +```css +@import '@rtcamp/frappe-ui-react/theme'; +@source "../../node_modules/@rtcamp/frappe-ui-react/dist"; ``` -Now, import the required components in your React app: +[Tailwind @source directive](https://tailwindcss.com/docs/functions-and-directives#source-directive) explicitly specifies source files that aren't picked up by Tailwind's automatic content detection. + +**(Tailwind v3 usage):** + +If you are using a Tailwind v3 configuration, you should take two steps: + +- Import the `theme-v3` CSS into your index.css file, and ensure that `index.css` is then imported in your `index.tsx`. + +- Import the Tailwind configuration from frappe-ui-react and either use it as a preset or extend your existing configuration. + +```css +/* index.css */ +@import '@rtcamp/frappe-ui-react/theme-v3'; +``` ```js -import { Button } from "@frappe-ui-react/frappe-ui-react"; +// tailwind.config.js in your project +module.exports = { + presets: [ + require('@rtcamp/frappe-ui-react/tailwind/preset') + ], + content: [ + path.resolve(__dirname, "../../node_modules/@rtcamp/frappe-ui-react/dist") + ] + // Additional configuration... +} +``` + +### Step 3: Import and Use Components + +You can now import components and use them in your project. + +```jsx +import './index.css'; +import { Button } from "@rtcamp/frappe-ui-react"; function App() { return (
); } export default App; ``` + +## Under the Hood + +This library is built on top of several excellent open-source projects: + +- **[TailwindCSS](https://github.com/tailwindlabs/tailwindcss)**: Utility-first CSS framework for building design system-based UIs. +- **[Headless UI](https://github.com/tailwindlabs/headlessui)**: Unstyled and accessible UI components. +- **[Radix UI](https://github.com/radix-ui/themes)**: Low-level, unstyled, and accessible UI primitives. +- **[React Quill](https://github.com/zenoamaro/react-quill)**: Rich text editor component for React. +- **[dayjs](https://github.com/iamkun/dayjs)**: Lightweight JavaScript library for working with dates. + +## Inspiration & Credits + +This project, **Frappe UI React**, is heavily inspired by the original **[Frappe UI](https://github.com/frappe/frappe-ui)** project. Frappe UI is a fantastic Vue.js component library, and our goal with Frappe UI React is to bring a similar aesthetic and component experience to the React ecosystem. \ No newline at end of file diff --git a/packages/frappe-ui-react/package.json b/packages/frappe-ui-react/package.json index 857ab1ce..fd279ba5 100644 --- a/packages/frappe-ui-react/package.json +++ b/packages/frappe-ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@rtcamp/frappe-ui-react", - "version": "1.0.0-beta.3", + "version": "1.0.2", "main": "dist/index.js", "module": "dist/index.js", "types": "dist-types/index.d.ts", diff --git a/packages/frappe-ui-react/src/components/alert/alert.stories.tsx b/packages/frappe-ui-react/src/components/alert/alert.stories.tsx new file mode 100644 index 00000000..18f6d506 --- /dev/null +++ b/packages/frappe-ui-react/src/components/alert/alert.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; + +import Alert from "./alert"; +import { Button } from "../button"; +import { BadgeInfo } from "lucide-react"; + +export default { + title: "Components/Alert", + component: Alert, + argTypes: { + title: { + control: "text", + description: "The title text of the alert", + }, + theme: { + control: { + type: "select", + options: ["yellow", "blue", "red", "green"], + }, + description: "Color theme of the alert", + }, + variant: { + control: { + type: "select", + options: ["subtle", "outline"], + }, + description: "Visual variant of the alert", + }, + description: { + control: "text", + description: "Description text displayed below the title", + }, + dismissable: { + control: "boolean", + description: "Whether the alert can be dismissed", + }, + visible: { + control: "boolean", + description: "Controls the visibility of the alert (controlled mode)", + }, + icon: { + control: false, + description: "Custom icon to display in the alert", + }, + footer: { + control: false, + description: "Custom footer content for the alert", + }, + }, + parameters: { + docs: { + source: { + type: "dynamic", + }, + }, + layout: "centered", + }, + tags: ["autodocs"], +} as Meta; + +type Story = StoryObj; + +const AlertTemplate: Story = { + render: (args) => ( +
+ +
+ ), +}; + +export const Success: Story = { + ...AlertTemplate, + args: { + title: "Source successfully added", + description: + "Discover the new feature to enhance your experience. See how it can help you.", + theme: "green", + }, +}; + +export const Warning: Story = { + ...AlertTemplate, + args: { + title: "Source successfully added", + description: + "Discover the new feature to enhance your experience. See how it can help you.", + theme: "yellow", + }, +}; + +export const Error: Story = { + ...AlertTemplate, + args: { + title: "Source successfully added", + description: + "Discover the new feature to enhance your experience. See how it can help you.", + theme: "red", + }, +}; + +export const Info: Story = { + ...AlertTemplate, + args: { + title: "Source successfully added", + description: + "Discover the new feature to enhance your experience. See how it can help you.", + theme: "blue", + }, +}; + +export const ControlledState: Story = { + render: (args) => { + const [visible, setVisible] = useState(true); + + return ( +
+
+ ); + }, + args: {}, +}; + +export const CustomSlots: Story = { + render: (args) => ( +
+ } + footer={() => ( +
+ ), + args: {}, +}; diff --git a/packages/frappe-ui-react/src/components/alert/alert.tsx b/packages/frappe-ui-react/src/components/alert/alert.tsx index 3cbd98ac..a7ba369c 100644 --- a/packages/frappe-ui-react/src/components/alert/alert.tsx +++ b/packages/frappe-ui-react/src/components/alert/alert.tsx @@ -1,53 +1,96 @@ -import React, { useMemo } from "react"; +/** + * External dependencies. + */ +import React, { useCallback, useMemo, useState } from "react"; +import { CircleCheck, CircleX, Info, TriangleAlert, X } from "lucide-react"; +import clsx from "clsx"; +/** + * Internal dependencies. + */ import type { AlertProps } from "./types"; const Alert: React.FC = ({ title, - type = "warning", - actions, - children, - ...rest + theme, + variant = "subtle", + description, + dismissable = true, + visible: controlledVisible, + onVisibleChange, + icon, + footer, }) => { + const [internalVisible, setInternalVisible] = useState(true); + + const isControlled = controlledVisible !== undefined; + const visible = isControlled ? controlledVisible : internalVisible; + + const handleDismiss = useCallback(() => { + if (isControlled) { + onVisibleChange?.(false); + } else { + setInternalVisible(false); + } + }, [isControlled, onVisibleChange]); + const classes = useMemo(() => { - const typeClasses: { [type: string]: string } = { - warning: "text-ink-gray-7 bg-surface-blue-1", + const subtleBgs = { + yellow: "bg-surface-amber-2", + blue: "bg-surface-blue-2", + red: "bg-surface-red-2", + green: "bg-surface-green-2", + }; + + if (variant === "outline") return "border border-outline-gray-3"; + + return theme ? subtleBgs[theme] : "bg-surface-gray-2"; + }, [theme, variant]); + + const iconConfig = useMemo(() => { + const data = { + yellow: { component: TriangleAlert, css: "text-ink-amber-3" }, + blue: { component: Info, css: "text-ink-blue-3" }, + red: { component: CircleX, css: "text-ink-red-3" }, + green: { component: CircleCheck, css: "text-ink-green-3" }, }; - return typeClasses[type] || typeClasses["warning"]; - }, [type]); + return theme ? data[theme] : null; + }, [theme]); + + if (!visible) return null; return ( -
-
- - - -
-
- {title && ( -

{title}

- )} -
{children}
- {actions && ( -
{actions}
- )} -
-
+
+ {icon ? ( + icon() + ) : iconConfig ? ( + + ) : null} + +
+ {title} + + {description ? ( + typeof description === "string" ? ( +

+ {description} +

+ ) : ( + description() + ) + ) : null}
+ + {dismissable && ( + + )} + + {footer ?
{footer()}
: null}
); }; diff --git a/packages/frappe-ui-react/src/components/alert/tests/alert.tsx b/packages/frappe-ui-react/src/components/alert/tests/alert.tsx index dc47a75a..bac97d52 100644 --- a/packages/frappe-ui-react/src/components/alert/tests/alert.tsx +++ b/packages/frappe-ui-react/src/components/alert/tests/alert.tsx @@ -1,29 +1,102 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; import Alert from "../alert"; describe("Alert Component", () => { it("should render with title", () => { - render(Test content); + render(); expect(screen.getByText("Test Title")).toBeInTheDocument(); }); - it("should render with children content", () => { - render(Test content); - expect(screen.getByText("Test content")).toBeInTheDocument(); + it("should render with description", () => { + render(); + expect(screen.getByText("Test description")).toBeInTheDocument(); }); - it("should render with actions", () => { - render(Action}>Test content); - expect(screen.getByRole("button")).toBeInTheDocument(); + it("should render with footer", () => { + render( + } /> + ); + expect(screen.getByText("Footer Action")).toBeInTheDocument(); }); - it("should apply warning styles by default", () => { - render(Test content); - const container = - screen.getByText("Test content").parentElement?.parentElement - ?.parentElement; - expect(container).toHaveClass("text-ink-gray-7 bg-surface-blue-1"); + it("should apply correct theme classes", () => { + const { container } = render(); + const alertElement = container.querySelector('[role="alert"]'); + expect(alertElement).toHaveClass("bg-surface-green-2"); + }); + + it("should apply outline variant classes", () => { + const { container } = render(); + const alertElement = container.querySelector('[role="alert"]'); + expect(alertElement).toHaveClass("border border-outline-gray-3"); + }); + + it("should render dismiss button when dismissable is true", () => { + render(); + const dismissButton = screen.getByLabelText("Dismiss alert"); + expect(dismissButton).toBeInTheDocument(); + }); + + it("should not render dismiss button when dismissable is false", () => { + render(); + const dismissButton = screen.queryByLabelText("Dismiss alert"); + expect(dismissButton).not.toBeInTheDocument(); + }); + + it("should hide alert when dismiss button is clicked (uncontrolled)", () => { + const { container } = render(); + const dismissButton = screen.getByLabelText("Dismiss alert"); + + fireEvent.click(dismissButton); + + const alertElement = container.querySelector('[role="alert"]'); + expect(alertElement).not.toBeInTheDocument(); + }); + + it("should call onVisibleChange when dismiss button is clicked (controlled)", () => { + const onVisibleChange = jest.fn(); + render( + + ); + const dismissButton = screen.getByLabelText("Dismiss alert"); + + fireEvent.click(dismissButton); + + expect(onVisibleChange).toHaveBeenCalledWith(false); + }); + + it("should not render when visible is false", () => { + const { container } = render(); + const alertElement = container.querySelector('[role="alert"]'); + expect(alertElement).not.toBeInTheDocument(); + }); + + it("should render custom icon", () => { + const CustomIcon = () =>
Icon
; + render( } />); + expect(screen.getByTestId("custom-icon")).toBeInTheDocument(); + }); + + it("should render theme-based icon for each theme", () => { + const themes = ["yellow", "blue", "red", "green"] as const; + + themes.forEach((theme) => { + const { container } = render(); + const icon = container.querySelector("svg"); + expect(icon).toBeInTheDocument(); + }); + }); + + it("should not render icon when no theme and no custom icon provided", () => { + const { container } = render(); + const svgs = container.querySelectorAll("svg"); + expect(svgs.length).toBe(1); // Only dismiss button icon }); }); diff --git a/packages/frappe-ui-react/src/components/alert/types.ts b/packages/frappe-ui-react/src/components/alert/types.ts index 0c7f432e..5ec9085c 100644 --- a/packages/frappe-ui-react/src/components/alert/types.ts +++ b/packages/frappe-ui-react/src/components/alert/types.ts @@ -1,7 +1,16 @@ +import type { ReactNode } from "react"; + export interface AlertProps { - title?: string; - type?: "warning"; - actions?: React.ReactNode; - children: React.ReactNode; - [key: string]: any; + title: string; + theme?: "yellow" | "blue" | "red" | "green"; + variant?: "subtle" | "outline"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + description?: string | ((args?: any) => ReactNode); + dismissable?: boolean; + visible?: boolean; + onVisibleChange?: (visible: boolean) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + icon?: (args?: any) => ReactNode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + footer?: (args?: any) => ReactNode; } diff --git a/packages/frappe-ui-react/src/components/autoComplete/autoComplete.stories.tsx b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.stories.tsx index 000cf1cd..6c670746 100644 --- a/packages/frappe-ui-react/src/components/autoComplete/autoComplete.stories.tsx +++ b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.stories.tsx @@ -158,7 +158,7 @@ export const SingleOptionWithPrefixSlots: Story = { value?.image && )} onChange={(_value) => { diff --git a/packages/frappe-ui-react/src/components/autoComplete/autoComplete.tsx b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.tsx index 3f889719..6416a890 100644 --- a/packages/frappe-ui-react/src/components/autoComplete/autoComplete.tsx +++ b/packages/frappe-ui-react/src/components/autoComplete/autoComplete.tsx @@ -272,9 +272,9 @@ const Autocomplete: React.FC = ({ const clearAll = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - if(multiple){ + if (multiple) { handleComboboxChange([]); - }else{ + } else { setQuery(""); } }, @@ -295,213 +295,223 @@ const Autocomplete: React.FC = ({ ); return ( - - {({ open: isComboboxOpen }) => ( - ( -
- {label && ( - - )} - -
- )} - body={({ isOpen: isPopoverOpen }) => - isPopoverOpen && ( -
- {!hideSearch && ( -
-
-
- query} - onChange={( - event: React.ChangeEvent - ) => { - cancelRef.current?.removeAttribute("inert"); - cancelRef.current?.removeAttribute("aria-hidden"); - setQuery(event.target.value); - }} - autoComplete="off" - onBlur={() => { - cancelRef.current?.removeAttribute("inert"); - cancelRef.current?.removeAttribute("aria-hidden"); - }} - placeholder="Search" - /> -
{ - cancelRef.current?.removeAttribute("inert"); - cancelRef.current?.removeAttribute("aria-hidden"); - }} - > - {loading ? ( - + )} + +
+ )} + body={({ isOpen: isPopoverOpen }) => + isPopoverOpen && ( +
+ {!hideSearch && ( +
+
+
+ query} + onChange={( + event: React.ChangeEvent + ) => { + cancelRef.current?.removeAttribute("inert"); + cancelRef.current?.removeAttribute("aria-hidden"); + setQuery(event.target.value); + }} + autoComplete="off" + onBlur={() => { + cancelRef.current?.removeAttribute("inert"); + cancelRef.current?.removeAttribute("aria-hidden"); + }} + placeholder="Search" /> - ) : ( - - )} + {loading ? ( + + ) : ( + + )} +
+
-
-
- )} + )} - - {groups.length === 0 ? ( -
  • - No results found -
  • - ) : ( - groups.map((group) => ( -
    - {group.group && !group.hideLabel && ( -
    - {group.group} -
    - )} - {group.items.slice(0, maxOptions).map((option, idx) => ( - - `flex cursor-pointer items-center justify-between rounded px-2.5 py-1.5 text-base ${ - focus ? "bg-surface-gray-3" : "" - } ${ - (option as Option).disabled ? "opacity-50" : "" - }` - } - > - <> -
    - {(itemPrefix || multiple) && ( -
    - {itemPrefix ? ( - itemPrefix(option as AutocompleteOption) - ) : isOptionSelected(option as Option) ? ( - - ) : ( -
    + + {groups.length === 0 ? ( +
  • + No results found +
  • + ) : ( + groups.map((group) => ( +
    + {group.group && !group.hideLabel && ( +
    + {group.group} +
    + )} + {group.items + .slice(0, maxOptions) + .map((option, idx) => ( + + `flex cursor-pointer items-center justify-between rounded px-2.5 py-1.5 text-base ${ + focus ? "bg-surface-gray-3" : "" + } ${ + (option as Option).disabled + ? "opacity-50" + : "" + }` + } + > + <> +
    + {(itemPrefix || multiple) && ( +
    + {itemPrefix ? ( + itemPrefix( + option as AutocompleteOption + ) + ) : isOptionSelected( + option as Option + ) ? ( + + ) : ( +
    + )} +
    )} + + {getLabel(option)} +
    - )} - - {getLabel(option)} - -
    - - {itemSuffix && ( -
    - {itemSuffix(option as Option)} - {(option as Option)?.description && ( -
    - {(option as Option).description} + + {itemSuffix && ( +
    + {itemSuffix(option as Option)} + {(option as Option)?.description && ( +
    + {(option as Option).description} +
    + )}
    )} -
    - )} - - - ))} -
    - )) - )} - - - {showFooter && multiple && ( -
    - {multiple ? ( -
    - {!areAllOptionsSelected && ( -
    - ) : ( -
    -
    + + + ))} +
    + )) )} -
    - )} -
    - ) - } - /> - )} - + + + {showFooter && multiple && ( +
    + {multiple ? ( +
    + {!areAllOptionsSelected && ( +
    + ) : ( +
    +
    + )} +
    + )} +
    + ) + } + /> + )} + +
    ); }; diff --git a/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx b/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx index 854a6433..c4a850bd 100644 --- a/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx +++ b/packages/frappe-ui-react/src/components/calendar/calendar.stories.tsx @@ -2,7 +2,12 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { CalendarConfig, CalendarEvent } from "./types"; import { Calendar } from "./calendar"; -import TabButtons from "../tabButtons"; +import { Button } from "../button"; +import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; +import { Select } from "../select"; +import { DatePicker } from "../datePicker"; +import { dayjs } from "../../utils/dayjs"; +import { action } from "storybook/actions"; const meta: Meta = { title: "Components/Calendar", @@ -122,9 +127,12 @@ export const Default: Story = { args: { config: { ...config, - createNewEvent: (event: CalendarEvent) => console.log("Create Event", event), - updateEventState: (event: CalendarEvent) => console.log("Update Event", event), - deleteEvent: (eventId: string|number) => console.log("Delete Event", eventId), + createNewEvent: (event: CalendarEvent) => + console.log("Create Event", event), + updateEventState: (event: CalendarEvent) => + console.log("Update Event", event), + deleteEvent: (eventId: string | number) => + console.log("Delete Event", eventId), }, events, }, @@ -143,39 +151,55 @@ export const CustomHeader: Story = { decrement, increment, enabledModes, - activeView, updateActiveView, + setCalendarDate, + formatter, }) => ( -
    -
    -

    - {currentMonthYear} -

    -
    - - -
    - ({ - value: mode.id, - label: mode.label, - }) - )} - value={activeView} - onChange={updateActiveView} + {displayValue} + + )} + +
    + +
    + setHour(Number(e.target.value))} - /> -
    -
    - setMinute(Number(e.target.value))} - /> -
    -
    - setSecond(Number(e.target.value))} + + {/* Time Picker Section */} +
    e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + ( + ( + + )} /> + )} + body={() => ( +
    e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {timeOptions.map((time) => { + const isSelected = timeValue === time; + return ( + + ); + })} +
    + )} + /> +
    + + {/* Footer Actions */} + {clearable && ( +
    +
    + +
    + {(dateValue || timeValue) && ( + + )}
    -
    - {/* Actions */} -
    - -
    + )}
    )} /> diff --git a/packages/frappe-ui-react/src/components/datePicker/types.ts b/packages/frappe-ui-react/src/components/datePicker/types.ts index 8563385f..8b0d9435 100644 --- a/packages/frappe-ui-react/src/components/datePicker/types.ts +++ b/packages/frappe-ui-react/src/components/datePicker/types.ts @@ -1,10 +1,17 @@ +export interface DatePickerChildrenProps { + togglePopover: () => void; + isOpen: boolean; + displayValue: string; +} + export interface DatePickerProps { value?: string | string[]; modelValue?: string | string[]; placeholder?: string; formatter?: (date: string) => string; readonly?: boolean; - inputClass?: string | string[] | Record; + inputClass?: string; + variant?: "subtle" | "outline" | "ghost"; placement?: | "top-start" | "top" @@ -19,10 +26,59 @@ export interface DatePickerProps { | "right" | "right-end"; label?: string; + clearable?: boolean; onChange?: (value: string | string[]) => void; + children?: (props: DatePickerChildrenProps) => React.ReactNode; +} + +export interface DateTimePickerProps { + value?: string; + placeholder?: string; + formatter?: (date: string) => string; + placement?: + | "top-start" + | "top" + | "top-end" + | "bottom-start" + | "bottom" + | "bottom-end" + | "left-start" + | "left" + | "left-end" + | "right-start" + | "right" + | "right-end"; + label?: string; + clearable?: boolean; + onChange?: (value: string) => void; + children?: (props: DatePickerChildrenProps) => React.ReactNode; +} + +export interface DateRangePickerProps { + value?: string[]; + placeholder?: string; + formatter?: (from: string, to: string) => string; + placement?: + | "top-start" + | "top" + | "top-end" + | "bottom-start" + | "bottom" + | "bottom-end" + | "left-start" + | "left" + | "left-end" + | "right-start" + | "right" + | "right-end"; + label?: string; + onChange?: (value: string[]) => void; + children?: (props: DatePickerChildrenProps) => React.ReactNode; } export type DatePickerEmits = { (event: "update:modelValue", value: string): void; (event: "change", value: string): void; }; + +export type DatePickerViewMode = "date" | "month" | "year"; diff --git a/packages/frappe-ui-react/src/components/datePicker/useDatePicker.ts b/packages/frappe-ui-react/src/components/datePicker/useDatePicker.ts index bccc7bd0..89f74817 100644 --- a/packages/frappe-ui-react/src/components/datePicker/useDatePicker.ts +++ b/packages/frappe-ui-react/src/components/datePicker/useDatePicker.ts @@ -1,26 +1,114 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { getDate, getDatesAfter, getDaysInMonth, getDateValue } from "./utils"; +import { + getDate, + getDatesAfter, + getDaysInMonth, + getDateValue, + getDateTimeValue, + formatDateTime12h, +} from "./utils"; +import { DatePickerViewMode } from "./types"; + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +/** + * Formats time in 12-hour format with AM/PM + * @param hours - Hour value (0-23) + * @param minutes - Minute value (0-59) + * @returns Formatted time string + */ +function formatTime12h(hours: number, minutes: number): string { + const period = hours >= 12 ? "pm" : "am"; + const h = hours % 12 || 12; + return `${h}:${minutes.toString().padStart(2, "0")} ${period}`; +} + +/** + * Parses a 12-hour format time string into hours and minutes + * @param timeStr - Time string in format "h:mm am/pm" + * @returns Object containing hours and minutes + */ +function parseTimeValue(timeStr: string): { hours: number; minutes: number } { + if (!timeStr) return { hours: 0, minutes: 0 }; + const [time, period] = timeStr.split(" "); + const [h, m] = time.split(":").map(Number); + let hours = h; + const periodLower = period?.toLowerCase(); + if (periodLower === "pm" && h !== 12) hours += 12; + if (periodLower === "am" && h === 12) hours = 0; + return { hours, minutes: m }; +} + +/** + * Generates an array of time options in 15-minute intervals for a full day + * @returns Array of time strings in 12-hour format (e.g., ["12:00 am", "12:15 am", ...]) + */ +function generateTimeOptions(): string[] { + const options: string[] = []; + for (let h = 0; h < 24; h++) { + for (let m = 0; m < 60; m += 15) { + options.push(formatTime12h(h, m)); + } + } + return options; +} export function useDatePicker({ value, onChange, + withTime = false, }: { value?: string | string[]; onChange?: (value: string | string[]) => void; + withTime?: boolean; } = {}) { const today = useMemo(() => getDate(), []); const [open, setOpen] = useState(false); - const [dateValue, setDateValue] = useState( - typeof value === "string" ? value : "" - ); + const [view, setView] = useState("date"); + const [dateValue, setDateValue] = useState(""); + const [timeValue, setTimeValue] = useState(withTime ? "12:00 am" : ""); const [currentYear, setCurrentYear] = useState(today.getFullYear()); const [currentMonth, setCurrentMonth] = useState( today.getMonth() + 1 ); useEffect(() => { - if (typeof value === "string") setDateValue(value); - }, [value]); + if (typeof value === "string" && value) { + if (withTime) { + const parts = value.split(" "); + const datePart = parts[0] || ""; + setDateValue(datePart); + + if (parts.length >= 2) { + const timePart = parts.slice(1).join(" "); + const timeMatch = timePart.match(/^(\d{1,2}):(\d{2})/); + if (timeMatch) { + const hours = parseInt(timeMatch[1], 10); + const minutes = parseInt(timeMatch[2], 10); + setTimeValue(formatTime12h(hours, minutes)); + } + } + } else { + setDateValue(value); + } + } else { + setDateValue(""); + setTimeValue(""); + } + }, [value, withTime]); const dates = useMemo(() => { if (!(currentYear && currentMonth)) return []; @@ -36,20 +124,13 @@ export function useDatePicker({ const daysInMonth = getDaysInMonth(monthIndex, year); const datesInMonth = getDatesAfter(firstDayOfMonth, daysInMonth - 1); - let allDates = [ + const allDates = [ ...leftPadding, firstDayOfMonth, ...datesInMonth, ...rightPadding, ]; - if (allDates.length < 42) { - const lastDate = allDates.at(-1); - if (lastDate) { - const finalPadding = getDatesAfter(lastDate, 42 - allDates.length); - allDates = allDates.concat(finalPadding); - } - } return allDates; }, [currentYear, currentMonth]); @@ -65,8 +146,8 @@ export function useDatePicker({ const formattedMonth = useMemo(() => { if (!(currentYear && currentMonth)) return ""; const date = getDate(currentYear, currentMonth - 1, 1); - const month = date.toLocaleString("en-US", { month: "long" }); - return `${month}, ${date.getFullYear()}`; + const month = date.toLocaleString("en-US", { month: "short" }); + return `${month} ${date.getFullYear()}`; }, [currentYear, currentMonth]); const prevMonth = useCallback(() => { @@ -93,16 +174,160 @@ export function useDatePicker({ (d: Date, close = false) => { const v = getDateValue(d); setDateValue(v); - onChange?.(v); + if (withTime) { + const { hours, minutes } = parseTimeValue(timeValue); + const newDate = getDate(v); + newDate.setHours(hours, minutes, 0, 0); + onChange?.(getDateTimeValue(newDate)); + } else { + onChange?.(v); + } if (close) setOpen(false); }, - [onChange] + [onChange, withTime, timeValue] ); const selectToday = useCallback(() => { selectDate(getDate(), true); }, [selectDate]); + const timeOptions = useMemo(() => generateTimeOptions(), []); + + const selectTime = useCallback( + (time: string) => { + setTimeValue(time); + if (dateValue) { + const { hours, minutes } = parseTimeValue(time); + const d = getDate(dateValue); + d.setHours(hours, minutes, 0, 0); + onChange?.(getDateTimeValue(d)); + } + }, + [dateValue, onChange] + ); + + const selectNow = useCallback(() => { + const now = getDate(); + const v = getDateValue(now); + setDateValue(v); + if (withTime) { + const time = formatTime12h(now.getHours(), now.getMinutes()); + setTimeValue(time); + onChange?.(getDateTimeValue(now)); + } else { + onChange?.(v); + } + setOpen(false); + }, [onChange, withTime]); + + const selectTomorrow = useCallback(() => { + const tomorrow = getDate(); + tomorrow.setDate(tomorrow.getDate() + 1); + const v = getDateValue(tomorrow); + setDateValue(v); + + if (withTime) { + const time = + timeValue || formatTime12h(getDate().getHours(), getDate().getMinutes()); + if (!timeValue) setTimeValue(time); + const { hours, minutes } = parseTimeValue(time); + tomorrow.setHours(hours, minutes, 0, 0); + onChange?.(getDateTimeValue(tomorrow)); + } else { + tomorrow.setHours(0, 0, 0, 0); + onChange?.(v); + } + }, [onChange, withTime, timeValue]); + + const clearValue = useCallback(() => { + setDateValue(""); + setTimeValue(""); + onChange?.(""); + setOpen(false); + }, [onChange]); + + const displayValue = useMemo(() => { + if (!dateValue) return ""; + if (withTime) { + const { hours, minutes } = parseTimeValue(timeValue); + const d = getDate(dateValue); + d.setHours(hours, minutes, 0, 0); + return formatDateTime12h(d); + } + return dateValue; + }, [dateValue, timeValue, withTime]); + + const cycleView = useCallback(() => { + setView((prev) => { + if (prev === "date") return "month"; + if (prev === "month") return "year"; + return "date"; + }); + }, []); + + const selectMonth = useCallback((monthIndex: number) => { + setCurrentMonth(monthIndex + 1); + setView("date"); + }, []); + + const selectYear = useCallback((year: number) => { + setCurrentYear(year); + setView("month"); + }, []); + + const yearRangeStart = useMemo( + () => currentYear - (currentYear % 12), + [currentYear] + ); + + const yearRange = useMemo( + () => Array.from({ length: 12 }, (_, i) => yearRangeStart + i), + [yearRangeStart] + ); + + const prev = useCallback(() => { + if (view === "date") { + setCurrentMonth((prev) => { + if (prev === 1) { + setCurrentYear((y) => y - 1); + return 12; + } + return prev - 1; + }); + } else if (view === "month") { + setCurrentYear((y) => y - 1); + } else { + setCurrentYear((y) => y - 12); + } + }, [view]); + + const next = useCallback(() => { + if (view === "date") { + setCurrentMonth((prev) => { + if (prev === 12) { + setCurrentYear((y) => y + 1); + return 1; + } + return prev + 1; + }); + } else if (view === "month") { + setCurrentYear((y) => y + 1); + } else { + setCurrentYear((y) => y + 12); + } + }, [view]); + + const resetView = useCallback(() => { + setView("date"); + if (dateValue) { + const selectedDate = getDate(dateValue); + if (!isNaN(selectedDate.getTime())) { + setCurrentYear(selectedDate.getFullYear()); + setCurrentMonth(selectedDate.getMonth() + 1); + } + } + }, [dateValue]); + return { open, setOpen, @@ -120,5 +345,24 @@ export function useDatePicker({ nextMonth, selectDate, selectToday, + view, + setView, + cycleView, + selectMonth, + selectYear, + yearRangeStart, + yearRange, + prev, + next, + resetView, + months: MONTHS, + timeValue, + setTimeValue, + timeOptions, + selectTime, + selectNow, + selectTomorrow, + clearValue, + displayValue, }; } diff --git a/packages/frappe-ui-react/src/components/datePicker/utils.ts b/packages/frappe-ui-react/src/components/datePicker/utils.ts index 61326bd0..ce12b1c4 100644 --- a/packages/frappe-ui-react/src/components/datePicker/utils.ts +++ b/packages/frappe-ui-react/src/components/datePicker/utils.ts @@ -27,6 +27,11 @@ export function getDateTimeValue(date: Date | string) { return dayjs(date).format("YYYY-MM-DD HH:mm:ss"); } +export function formatDateTime12h(date: Date | string) { + if (!date || date.toString() === "Invalid Date") return ""; + return dayjs(date).format("YYYY-MM-DD h:mm a"); +} + export function getDatesAfter(date: Date, count: number) { let incrementer = 1; if (count < 0) { diff --git a/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx b/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx index c0d0913d..0f3bfa12 100644 --- a/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx +++ b/packages/frappe-ui-react/src/components/dropdown/dropdown.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; +import { useState } from "react"; import Dropdown from "./dropdown"; import { Button } from "../button"; -import { DropdownOptions } from "./types"; +import type { DropdownOptions } from "./types"; export default { title: "Components/Dropdown", @@ -73,18 +74,6 @@ const groupedActions: DropdownOptions = [ { label: "Export", icon: "download", - submenu: [ - { - label: "Export as PDF", - icon: "file-text", - onClick: () => action("Export as PDF clicked")(), - }, - { - label: "Export as CSV", - icon: "file", - onClick: () => action("Export as CSV clicked")(), - }, - ], }, { label: "Share", @@ -244,11 +233,77 @@ export const CenterAligned: StoryObj = { }; export const WithSubmenus: StoryObj = { - ...DropdownTemplate, args: { options: submenuActions, button: { label: "With Submenus" }, }, + render: function Render(args) { + const [collaborateValue, setCollaborateValue] = useState(false); + + const options: DropdownOptions = [ + ...submenuActions, + { + label: "Collaborate", + switch: true, + icon: "file-text", + switchValue: collaborateValue, + onClick: (val) => { + setCollaborateValue(val as boolean); + action("Collaborate switch value:")(val); + }, + }, + ]; + + return ( +
    + +
    + ); + }, +}; + +export const WithSwitches: StoryObj = { + args: { + button: { label: "With Switches" }, + }, + render: function Render(args) { + const [lockValue, setLockValue] = useState(true); + const [collaborateValue, setCollaborateValue] = useState(false); + + const options: DropdownOptions = [ + { + label: "Rename", + icon: "edit", + onClick: () => action("Rename clicked")(), + }, + { + label: "Lock", + icon: "lock", + switch: true, + switchValue: lockValue, + onClick: (val) => { + setLockValue(val as boolean); + action("Lock switch value:")(val); + }, + }, + { + label: "Collaborate", + switch: true, + icon: "users", + switchValue: collaborateValue, + onClick: (val) => { + setCollaborateValue(val as boolean); + action("Collaborate switch value:")(val); + }, + }, + ]; + + return ( +
    + +
    + ); + }, }; export const WithNestedSubmenus: StoryObj = { diff --git a/packages/frappe-ui-react/src/components/dropdown/dropdown.tsx b/packages/frappe-ui-react/src/components/dropdown/dropdown.tsx index f63950ad..7b8d753f 100644 --- a/packages/frappe-ui-react/src/components/dropdown/dropdown.tsx +++ b/packages/frappe-ui-react/src/components/dropdown/dropdown.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from "react"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { Button, ButtonProps } from "../button"; +import { Switch } from "../switch"; import type { DropdownProps, DropdownOption, @@ -14,7 +15,7 @@ const cssClasses = { dropdownContent: "min-w-40 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-black focus:outline-none dropdown-content border border-outline-gray-1 z-100", groupContainer: "p-1.5", - groupLabel: "flex h-7 items-center px-2 text-sm font-medium text-ink-gray-5", + groupLabel: "flex h-7 items-center px-2 text-sm font-medium text-ink-gray-7", itemLabel: "whitespace-nowrap", itemIcon: "mr-2 h-4 w-4 flex-shrink-0", chevronIcon: "ml-auto h-4 w-4 flex-shrink-0", @@ -61,10 +62,13 @@ const Dropdown: React.FC = ({ label: option.label, icon: option.icon, component: option.component, - onClick: () => handleItemClick(option), + onClick: option.switch ? option.onClick : () => handleItemClick(option), submenu: option.submenu, condition: option.condition, theme: option.theme, + disabled: option.disabled, + switch: option.switch, + switchValue: option.switchValue, }; }, [handleItemClick] @@ -151,6 +155,29 @@ const Dropdown: React.FC = ({ if (item.component) { const CustomComponent = item.component; return ; + } else if (item.switch) { + return ( +
    e.preventDefault()} + > + {item.icon && + (typeof item.icon === "string" ? ( + + ) : React.isValidElement(item.icon) ? ( + item.icon + ) : null)} + {item.label} + item.onClick?.(checked)} + /> +
    + ); } else if (item.submenu) { return ( @@ -201,7 +228,7 @@ const Dropdown: React.FC = ({ subItem.onClick?.()} > {renderDropdownItem(subItem)} @@ -268,7 +295,7 @@ const Dropdown: React.FC = ({ )} {group.items.map((item) => (
    - + !item.switch && item.onClick?.()}> {renderDropdownItem(item)}
    diff --git a/packages/frappe-ui-react/src/components/dropdown/types.ts b/packages/frappe-ui-react/src/components/dropdown/types.ts index e673f458..d05eea6e 100644 --- a/packages/frappe-ui-react/src/components/dropdown/types.ts +++ b/packages/frappe-ui-react/src/components/dropdown/types.ts @@ -4,7 +4,7 @@ import { ButtonTheme } from "../button"; export interface DropdownOption { label: string; - onClick?: () => void; + onClick?: (val?: boolean) => void; link?: string; icon?: string | ReactNode; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -12,6 +12,9 @@ export interface DropdownOption { theme?: ButtonTheme; submenu?: DropdownOptions; condition?: () => boolean; + disabled?: boolean; + switch?: boolean; + switchValue?: boolean; } export interface DropdownGroupOption { diff --git a/packages/frappe-ui-react/src/components/formControl/formControl.stories.tsx b/packages/frappe-ui-react/src/components/formControl/formControl.stories.tsx index e967fefa..5a108fe4 100644 --- a/packages/frappe-ui-react/src/components/formControl/formControl.stories.tsx +++ b/packages/frappe-ui-react/src/components/formControl/formControl.stories.tsx @@ -3,6 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import FormControl from "./formControl"; import FeatherIcon from "../featherIcon"; import { useState } from "react"; +import { Avatar } from "../avatar"; const meta: Meta = { title: "Components/FormControl", @@ -156,11 +157,13 @@ export const Autocomplete: Story = { render: (args) => { const [value, setValue] = useState(""); return ( - setValue(_value)} - /> +
    + setValue(_value)} + /> +
    ); }, }; @@ -182,10 +185,33 @@ export const Checkbox: Story = { }, }; -export const WithPrefixIcon: Story = { +export const PrefixSlotIcon: Story = { args: { type: "text", placeholder: "", prefix: () => , }, }; + +export const SuffixSlotIcon: Story = { + args: { + type: "text", + placeholder: "", + suffix: () => , + }, +}; + +export const PrefixSlotAvatar: Story = { + args: { + type: "text", + placeholder: "", + prefix: () => ( + + ), + }, +}; diff --git a/packages/frappe-ui-react/src/components/index.ts b/packages/frappe-ui-react/src/components/index.ts index dc7053e8..f58b081e 100644 --- a/packages/frappe-ui-react/src/components/index.ts +++ b/packages/frappe-ui-react/src/components/index.ts @@ -20,6 +20,8 @@ export * from "./formControl"; export * from "./gridLayout"; export * from "./hooks"; export * from "./listview"; +export * from "./multiSelect"; +export * from "./monthPicker"; export * from "./password"; export * from "./progress"; export * from "./popover"; @@ -33,6 +35,7 @@ export * from "./tabs"; export * from "./textInput"; export * from "./textarea"; export { default as TextEditor } from "./textEditor"; +export * from "./timePicker"; export * from "./toast"; export * from "./tooltip"; export * from "./tree"; diff --git a/packages/frappe-ui-react/src/components/listview/listHeader.tsx b/packages/frappe-ui-react/src/components/listview/listHeader.tsx index 605f3ce3..c1f03207 100644 --- a/packages/frappe-ui-react/src/components/listview/listHeader.tsx +++ b/packages/frappe-ui-react/src/components/listview/listHeader.tsx @@ -35,7 +35,7 @@ const ListHeader: React.FC = ({ children }) => { item={column} lastItem={index === list.columns.length - 1} onColumnWidthUpdated={(width: number) => { - list.updateColumnWidth(index, width); + list.options.updateColumnWidth(index, width); }} /> ))} diff --git a/packages/frappe-ui-react/src/components/listview/listView.stories.tsx b/packages/frappe-ui-react/src/components/listview/listView.stories.tsx index 8b9c0888..b6869d60 100644 --- a/packages/frappe-ui-react/src/components/listview/listView.stories.tsx +++ b/packages/frappe-ui-react/src/components/listview/listView.stories.tsx @@ -1,4 +1,5 @@ -import { Meta, StoryObj } from "@storybook/react-vite"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useEffect, useState } from "react"; import ListView from "./listView"; import { Avatar } from "../avatar"; @@ -292,18 +293,31 @@ export const SimpleList: Story = { export const CustomList: Story = { render: (args) => { - return ( + const [columns, _setColumns] = useState([]); + useEffect(() => { + _setColumns(custom_columns); + }, []); + + return columns.length > 0 ? (
    - + <> - {custom_columns.map((column, index) => ( - + {columns.map((column, index) => ( + { + _setColumns((prevColumns) => { + const newColumns = [...prevColumns]; + newColumns[index] = { + ...newColumns[index], + width: `${width}px`, + }; + return newColumns; + }); + }} + >
    {custom_rows.map((row) => ( - {custom_columns.map((column, index) => { - //@ts-expects-error + {columns.map((column, index) => { + //@ts-expect-error item type const item = row[column.key]; return (
    @@ -383,6 +397,8 @@ export const CustomList: Story = {
    + ) : ( + <> ); }, args: { @@ -423,7 +439,7 @@ export const GroupedRows: Story = { export const CellSlot: Story = { render: (args) => { - //@ts-expects-error + //@ts-expect-error const CustomCell = ({ item, column }) => { if (column.key === "status") { return {item}; diff --git a/packages/frappe-ui-react/src/components/listview/listprovider.tsx b/packages/frappe-ui-react/src/components/listview/listprovider.tsx index 2c7bc539..4b2ab143 100644 --- a/packages/frappe-ui-react/src/components/listview/listprovider.tsx +++ b/packages/frappe-ui-react/src/components/listview/listprovider.tsx @@ -1,4 +1,10 @@ -import React, { ReactNode, useCallback, useMemo, useState } from "react"; +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { ListContext, ListOptionsProps } from "./listContext"; interface ListProviderProps { @@ -20,6 +26,10 @@ export const ListProvider: React.FC = ({ const [activeRow, setActiveRow] = useState(null); const [_columns, setColumns] = useState(columns); + useEffect(() => { + setColumns(columns); + }, [columns]); + const updateColumnWidth = useCallback((index: number, width: number) => { setColumns((prevColumns) => { const newColumns = [...prevColumns]; @@ -58,8 +68,9 @@ export const ListProvider: React.FC = ({ title: "No Data", description: "No data available", }, + updateColumnWidth, }; - }, [options]); + }, [options, updateColumnWidth]); const showGroupedRows = useMemo( () => rows.every((row) => row.group && row.rows && Array.isArray(row.rows)), @@ -135,7 +146,6 @@ export const ListProvider: React.FC = ({ toggleAllRows, emptyState: options.emptyState, setColumns: () => {}, - updateColumnWidth, }, }), [ @@ -150,7 +160,6 @@ export const ListProvider: React.FC = ({ allRowsSelected, toggleRow, toggleAllRows, - updateColumnWidth, ] ); return ( diff --git a/packages/frappe-ui-react/src/components/monthPicker/index.ts b/packages/frappe-ui-react/src/components/monthPicker/index.ts new file mode 100644 index 00000000..671d7dc3 --- /dev/null +++ b/packages/frappe-ui-react/src/components/monthPicker/index.ts @@ -0,0 +1,3 @@ +export { default as MonthPicker } from "./monthPicker"; +export * from "./monthPicker"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/monthPicker/monthPicker.stories.tsx b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.stories.tsx new file mode 100644 index 00000000..86ff895c --- /dev/null +++ b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; + +import MonthPicker from "./monthPicker"; +import type { MonthPickerProps } from "./types"; + +export default { + title: "Components/MonthPicker", + component: MonthPicker, + tags: ["autodocs"], + argTypes: { + value: { + control: "text", + description: + "Selected month value in 'Month Year' format (e.g., 'January 2026').", + }, + placeholder: { + control: "text", + description: "Placeholder text for the MonthPicker button.", + }, + className: { + control: "text", + description: "CSS class names to apply to the button.", + }, + placement: { + control: "select", + options: [ + "top-start", + "top", + "top-end", + "bottom-start", + "bottom", + "bottom-end", + "left-start", + "left", + "left-end", + "right-start", + "right", + "right-end", + ], + description: "Popover placement relative to the target.", + }, + onChange: { + action: "onChange", + description: "Callback fired when the month value changes.", + }, + }, + parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + const [value, setValue] = useState(""); + return ( +
    + +
    + ); + }, + args: { + placeholder: "Select month", + }, +}; + +export const FitWidth: Story = { + render: (args) => { + const [value, setValue] = useState(""); + return ( +
    + +
    + ); + }, + args: { + placeholder: "Select month", + }, +}; diff --git a/packages/frappe-ui-react/src/components/monthPicker/monthPicker.tsx b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.tsx new file mode 100644 index 00000000..f20bdd58 --- /dev/null +++ b/packages/frappe-ui-react/src/components/monthPicker/monthPicker.tsx @@ -0,0 +1,156 @@ +/** + * External dependencies. + */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight, Calendar } from "lucide-react"; +import clsx from "clsx"; + +/** + * Internal dependencies. + */ +import { dayjs } from "../../utils/dayjs"; +import { Popover } from "../popover"; +import { Button } from "../button"; +import { MonthPickerProps } from "./types"; + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const MonthPicker = ({ + value, + placeholder = "Select month", + className, + placement, + onChange, +}: MonthPickerProps) => { + const [open, setOpen] = useState(false); + const [viewMode, setViewMode] = useState<"month" | "year">("month"); + const today = useMemo(() => new Date(), []); + const [currentYear, setCurrentYear] = useState(today.getFullYear()); + + const parseValue = useCallback((val: string) => { + if (!val) return null; + const parsed = dayjs(val, "MMMM YYYY"); + if (parsed.isValid()) { + return { month: parsed.format("MMMM"), year: parsed.year() }; + } + return null; + }, []); + + const yearRangeStart = useMemo( + () => currentYear - (currentYear % 12), + [currentYear] + ); + + const yearRange = useMemo( + () => Array.from({ length: 12 }, (_, i) => yearRangeStart + i), + [yearRangeStart] + ); + + const pickerList = useMemo( + () => (viewMode === "year" ? yearRange : MONTHS), + [viewMode, yearRange] + ); + + const toggleViewMode = useCallback(() => { + setViewMode((prevMode) => (prevMode === "month" ? "year" : "month")); + }, []); + + const prev = useCallback(() => { + setCurrentYear((y) => (viewMode === "year" ? y - 12 : y - 1)); + }, [viewMode]); + + const next = useCallback(() => { + setCurrentYear((y) => (viewMode === "year" ? y + 12 : y + 1)); + }, [viewMode]); + + const handleOpenChange = useCallback((isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) setViewMode("month"); + }, []); + + const handleOnClick = useCallback( + (v: string | number) => { + const parts = (value || "").split(" "); + const indexToModify = viewMode === "year" ? 1 : 0; + parts[indexToModify] = String(v); + const newValue = parts.join(" "); + onChange?.(newValue); + }, + [value, viewMode, onChange] + ); + + useEffect(() => { + const parsed = parseValue(value || ""); + if (parsed) { + setCurrentYear(parsed.year); + } + }, [value, parseValue]); + + return ( + ( + + )} + popoverClass="w-min!" + body={() => ( +
    +
    + + + + + +
    + +
    + +
    + {pickerList.map((month, index) => ( + + ))} +
    +
    + )} + /> + ); +}; + +export default MonthPicker; diff --git a/packages/frappe-ui-react/src/components/monthPicker/types.ts b/packages/frappe-ui-react/src/components/monthPicker/types.ts new file mode 100644 index 00000000..bee6d040 --- /dev/null +++ b/packages/frappe-ui-react/src/components/monthPicker/types.ts @@ -0,0 +1,19 @@ +export interface MonthPickerProps { + value?: string; + placeholder?: string; + className?: string; + placement?: + | "top-start" + | "top" + | "top-end" + | "bottom-start" + | "bottom" + | "bottom-end" + | "left-start" + | "left" + | "left-end" + | "right-start" + | "right" + | "right-end"; + onChange?: (value: string) => void; +} diff --git a/packages/frappe-ui-react/src/components/multiSelect/index.ts b/packages/frappe-ui-react/src/components/multiSelect/index.ts new file mode 100644 index 00000000..4c938f42 --- /dev/null +++ b/packages/frappe-ui-react/src/components/multiSelect/index.ts @@ -0,0 +1,2 @@ +export { MultiSelect } from "./multiSelect"; +export * from "./types"; diff --git a/packages/frappe-ui-react/src/components/multiSelect/multiSelect.stories.tsx b/packages/frappe-ui-react/src/components/multiSelect/multiSelect.stories.tsx new file mode 100644 index 00000000..c12da41e --- /dev/null +++ b/packages/frappe-ui-react/src/components/multiSelect/multiSelect.stories.tsx @@ -0,0 +1,157 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CheckCheck, Trash2 } from "lucide-react"; + +import { MultiSelect } from "./multiSelect"; +import Button from "../button/button"; +import Avatar from "../avatar/avatar"; + +const meta: Meta = { + title: "Components/MultiSelect", + component: MultiSelect, + parameters: { + docs: { source: { type: "dynamic" } }, + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + value: { + control: "object", + description: "Array of selected values", + }, + options: { + control: "object", + description: "Array of options to display in the dropdown", + }, + placeholder: { + control: "text", + description: "Placeholder text when no options are selected", + }, + hideSearch: { + control: "boolean", + description: "Hide the search input in the dropdown", + }, + loading: { + control: "boolean", + description: "Show loading indicator", + }, + onChange: { + action: "changed", + description: "Callback when selection changes", + }, + renderOption: { + control: false, + description: "Custom render function for each option", + }, + renderFooter: { + control: false, + description: "Custom render function for the footer", + }, + }, + args: { + placeholder: "Select option", + hideSearch: false, + loading: false, + }, +}; + +export default meta; +type Story = StoryObj; + +const img = + "https://images.unsplash.com/photo-1502741338009-cac2772e18bc?w=100&h=100&fit=crop"; + +const options = [ + { value: "red-apple", label: "Red Apple", img }, + { value: "blueberry-burst", label: "Blueberry Burst", img }, + { value: "orange-grove", label: "Orange Grove", img }, + { value: "banana-split", label: "Banana Split", img }, + { value: "grapes-cluster", label: "Grapes Cluster", img }, + { value: "kiwi-slice", label: "Kiwi Slice", img }, + { value: "mango-fusion", label: "Mango Fusion", img }, +]; + +export const Default: Story = { + args: { + options: options, + value: [], + placeholder: "Select fruit", + onChange: () => {}, + }, + render: (args) => { + const [value, setValue] = useState([]); + return ( +
    + +
    + ); + }, +}; + +export const OptionSlot: Story = { + name: "Option slot", + args: { + options: options, + value: [], + placeholder: "Select fruit", + onChange: () => {}, + }, + render: (args) => { + const [value, setValue] = useState([]); + return ( +
    + ( +
    + + + + {item.label} +
    + )} + /> +
    + ); + }, +}; + +export const FooterSlot: Story = { + name: "Footer slot", + args: { + options: options, + value: [], + onChange: () => {}, + }, + render: (args) => { + const [value, setValue] = useState([]); + return ( +
    + ( +
    + + +
    + )} + /> +
    + ); + }, +}; diff --git a/packages/frappe-ui-react/src/components/multiSelect/multiSelect.tsx b/packages/frappe-ui-react/src/components/multiSelect/multiSelect.tsx new file mode 100644 index 00000000..b069afee --- /dev/null +++ b/packages/frappe-ui-react/src/components/multiSelect/multiSelect.tsx @@ -0,0 +1,199 @@ +/** + * External dependencies + */ +import React, { useState, useMemo, useRef, useEffect } from "react"; +import { + Combobox, + ComboboxInput, + ComboboxOption, + ComboboxOptions, +} from "@headlessui/react"; +import { Check, ChevronDown, X } from "lucide-react"; + +/** + * Internal dependencies. + */ +import Popover from "../popover/popover"; +import Button from "../button/button"; +import LoadingIndicator from "../loadingIndicator"; +import type { MultiSelectOption, MultiSelectProps } from "./types"; +import clsx from "clsx"; + +export const MultiSelect: React.FC = ({ + value = [], + options, + placeholder = "Select option", + hideSearch = false, + loading = false, + compareFn = ( + a: NoInfer | object, + b: NoInfer | object + //@ts-expect-error -- this is fine since we have specified object type in documentation + ) => a?.value === b?.value, + onChange, + renderOption, + renderFooter, +}) => { + const [query, setQuery] = useState(""); + const [showOptions, setShowOptions] = useState(false); + const searchInputRef = useRef(null); + + const selectedOptionObjects = useMemo(() => { + return value + .map((val) => options.find((opt) => opt.value === val)) + .filter((opt): opt is MultiSelectOption => opt !== undefined); + }, [value, options]); + + const selectedOptions = useMemo(() => { + if (selectedOptionObjects.length === 0) return placeholder; + const labels = selectedOptionObjects.map((opt) => opt.label); + return labels.join(", "); + }, [selectedOptionObjects, placeholder]); + + const filteredOptions = useMemo(() => { + if (!query) return options; + const lowerQuery = query.toLowerCase(); + return options.filter((opt) => + opt.label.toLowerCase().includes(lowerQuery) + ); + }, [options, query]); + + const clearAll = () => { + setQuery(""); + onChange?.([]); + }; + + const selectAll = () => { + setQuery(""); + const allValues = options.filter((opt) => !opt.disabled).map((opt) => opt.value); + onChange?.(allValues); + }; + + const clearSearch = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setQuery(""); + searchInputRef.current?.focus(); + }; + + const handleChange = (newValue: MultiSelectOption[]) => { + onChange?.(newValue.map((opt) => opt.value)); + }; + + useEffect(() => { + if (showOptions && searchInputRef.current && !hideSearch) { + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + }); + } + }, [showOptions, hideSearch]); + + return ( + ( + + )} + body={() => ( +
    + +
    + {!hideSearch && ( +
    + query} + onChange={(e) => setQuery(e.target.value)} + placeholder="Search for..." + className="bg-transparent p-0 focus:outline-0 border-0 focus:border-0 focus:ring-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full" + /> +
    + {loading && ( + + )} + {query && ( + + )} +
    +
    + )} + +
    + + {filteredOptions.length === 0 && ( +
    + No results found +
    + )} + + {filteredOptions.map((item) => ( + + {({ selected }) => ( + <> + + {renderOption ? renderOption(item) : item.label} + + {selected && ( +
    + +
    + )} + + )} +
    + ))} +
    + +
    + + {renderFooter ? ( + renderFooter({ clearAll, selectAll }) + ) : ( +
    + + +
    + )} +
    +
    +
    +
    + )} + /> + ); +}; diff --git a/packages/frappe-ui-react/src/components/multiSelect/types.ts b/packages/frappe-ui-react/src/components/multiSelect/types.ts new file mode 100644 index 00000000..f4775278 --- /dev/null +++ b/packages/frappe-ui-react/src/components/multiSelect/types.ts @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; + +export interface MultiSelectOption { + label: string; + value: string; + disabled?: boolean; +} + +export interface MultiSelectProps { + value?: string[]; + options: MultiSelectOption[]; + placeholder?: string; + hideSearch?: boolean; + loading?: boolean; + compareFn?: ( + a: NoInfer | object, + b: NoInfer | object + ) => boolean; + onChange?: (value: string[]) => void; + renderOption?: (option: MultiSelectOption) => ReactNode; + renderFooter?: (props: { + clearAll: () => void; + selectAll: () => void; + }) => ReactNode; +} diff --git a/packages/frappe-ui-react/src/components/popover/popover.tsx b/packages/frappe-ui-react/src/components/popover/popover.tsx index 18050dfb..93b9a5c3 100644 --- a/packages/frappe-ui-react/src/components/popover/popover.tsx +++ b/packages/frappe-ui-react/src/components/popover/popover.tsx @@ -16,6 +16,7 @@ function getOrCreatePopoverRoot(): HTMLElement { if (!root) { root = document.createElement("div"); root.id = popoverRootId; + root.style.position = "absolute"; document.body.appendChild(root); } return root; @@ -363,7 +364,7 @@ const Popover: React.FC = ({ {createPortal(
    ; const Template: StoryObj = { + args: { + value: "", + options: [ + { label: "John Doe", value: "john-doe" }, + { label: "Jane Doe", value: "jane-doe" }, + { label: "John Smith", value: "john-smith" }, + { label: "Jane Smith", value: "jane-smith", disabled: true }, + { label: "John Wayne", value: "john-wayne" }, + { label: "Jane Wayne", value: "jane-wayne" }, + ], + }, render: (args) => { const [value, setValue] = useState(args.value || ""); @@ -58,16 +70,7 @@ const Template: StoryObj = {