From b3fa8d6d7b8c9d4a8fe1181078503067e16b0bb1 Mon Sep 17 00:00:00 2001 From: Vineeth Asok Kumar Date: Tue, 26 Sep 2023 20:16:38 +0200 Subject: [PATCH] Rewrite select (#137) * Add select changes * Add Select Changes * Remove drag feature * Fix Select changes * Fix tests * Fix sort issue * Fix package install * Remove yarn lock * Fix initial loading of the item * Fix showSearch in storybook * Update storybook orientation * Call onSelect on value change * Add Select Changes * Fix Hidden Select Component * Fix linting issues * Fix show Search in Select * Fix Select Open State issue * Add Select Changes * Add Select changes * Add Select Changes * Fix Multi select sorting * Remove open from story book * Fix initial selection and also props naming * Add styling changes * Change onChange to onSelect in Select and MultiSelect props * Fix Select Test * Add types * Fix incorrect assignment * Fix Select Styling * Add Ellipsis changes --- package-lock.json | 509 +++------------ package.json | 6 +- src/components/Badge/Badge.tsx | 62 +- .../EllipsisContent/EllipsisContent.tsx | 36 ++ src/components/GenericMenu.tsx | 5 +- src/components/IconWrapper/IconWrapper.tsx | 55 ++ src/components/Select/MultiSelect.stories.tsx | 142 +++++ src/components/Select/MultiSelect.test.tsx | 289 +++++++++ src/components/Select/MultiSelect.tsx | 80 +++ src/components/Select/MultiSelectValue.tsx | 104 ++++ src/components/Select/Select.stories.tsx | 52 -- src/components/Select/Select.tsx | 574 ----------------- src/components/Select/SelectContext.tsx | 127 ---- .../Select/SingleSelect.stories.tsx | 138 ++++ ...{Select.test.tsx => SingleSelect.test.tsx} | 133 +++- src/components/Select/SingleSelect.tsx | 83 +++ src/components/Select/SingleSelectValue.tsx | 40 ++ .../Select/common/InternalSelect.tsx | 587 ++++++++++++++++++ src/components/Select/common/OptionContext.ts | 20 + src/components/Select/common/SelectStyled.tsx | 237 +++++++ src/components/Select/common/types.ts | 91 +++ src/components/Select/common/useOption.tsx | 15 + src/components/Select/selectOptions.ts | 31 + src/components/Select/useSelect.tsx | 10 - src/components/commonElement.tsx | 22 +- src/components/index.ts | 5 +- src/components/types.ts | 4 +- 27 files changed, 2208 insertions(+), 1249 deletions(-) create mode 100644 src/components/EllipsisContent/EllipsisContent.tsx create mode 100644 src/components/IconWrapper/IconWrapper.tsx create mode 100644 src/components/Select/MultiSelect.stories.tsx create mode 100644 src/components/Select/MultiSelect.test.tsx create mode 100644 src/components/Select/MultiSelect.tsx create mode 100644 src/components/Select/MultiSelectValue.tsx delete mode 100644 src/components/Select/Select.stories.tsx delete mode 100644 src/components/Select/Select.tsx delete mode 100644 src/components/Select/SelectContext.tsx create mode 100644 src/components/Select/SingleSelect.stories.tsx rename src/components/Select/{Select.test.tsx => SingleSelect.test.tsx} (62%) create mode 100644 src/components/Select/SingleSelect.tsx create mode 100644 src/components/Select/SingleSelectValue.tsx create mode 100644 src/components/Select/common/InternalSelect.tsx create mode 100644 src/components/Select/common/OptionContext.ts create mode 100644 src/components/Select/common/SelectStyled.tsx create mode 100644 src/components/Select/common/types.ts create mode 100644 src/components/Select/common/useOption.tsx create mode 100644 src/components/Select/selectOptions.ts delete mode 100644 src/components/Select/useSelect.tsx diff --git a/package-lock.json b/package-lock.json index 8d32e360..9e511f94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,9 +22,10 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.4", "@radix-ui/react-tooltip": "^1.0.6", - "cmdk": "^0.2.0", "lodash": "^4.17.21", - "react-syntax-highlighter": "^15.5.0" + "react-sortablejs": "^6.1.4", + "react-syntax-highlighter": "^15.5.0", + "sortablejs": "^1.15.0" }, "devDependencies": { "@radix-ui/react-switch": "^1.0.2", @@ -48,6 +49,7 @@ "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", "@types/react-syntax-highlighter": "^15.5.7", + "@types/sortablejs": "^1.15.2", "@types/styled-components": "^5.1.26", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", @@ -4287,239 +4289,6 @@ } } }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.0.tgz", - "integrity": "sha512-Yn9YU+QlHYLWwV1XfKiqnGVpWYWk6MeBVM6x/bcoyPvxgjQGoeT35482viLPctTMWoMw0PoHgqfSox7Ig+957Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-dismissable-layer": "1.0.0", - "@radix-ui/react-focus-guards": "1.0.0", - "@radix-ui/react-focus-scope": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-portal": "1.0.0", - "@radix-ui/react-presence": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-slot": "1.0.0", - "@radix-ui/react-use-controllable-state": "1.0.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.4" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", - "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", - "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", - "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.0.tgz", - "integrity": "sha512-n7kDRfx+LB1zLueRDvZ1Pd0bxdJWDUZNQ/GWoxDn2prnuJKRdxsjulejX/ePkOsLi2tTm6P24mDqlMSgQpsT6g==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-use-callback-ref": "1.0.0", - "@radix-ui/react-use-escape-keydown": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.0.tgz", - "integrity": "sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.0.tgz", - "integrity": "sha512-C4SWtsULLGf/2L4oGeIHlvWQx7Rf+7cX/vKOAD2dXW0A1b5QXwi3wWeaEgW+wn+SEVrraMUk05vLU9fZZz5HbQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-use-callback-ref": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", - "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.0.tgz", - "integrity": "sha512-a8qyFO/Xb99d8wQdu4o7qnigNjTPG123uADNecz0eX4usnQEj7o+cG4ZX4zkqq98NYekT7UoEQIjxBNWIFuqTA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz", - "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-use-layout-effect": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz", - "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz", - "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", - "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", - "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.0.tgz", - "integrity": "sha512-JwfBCUIfhXRxKExgIqGa4CQsiMemo1Xt0W/B4ei3fpzpvPENKpMKQ8mZSB6Acj3ebrAEgi2xiQvcI1PAAodvyg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", - "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.4.tgz", - "integrity": "sha512-xGVKJJr0SJGQVirVFAUZ2k1QLyO6m+2fy0l8Qawbp5Jgrv3DeLalrfMNBFSlmz5kriGGzsVBtGVnf4pTKIhhWA==", - "dependencies": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", @@ -7850,6 +7619,11 @@ "@types/node": "*" } }, + "node_modules/@types/sortablejs": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.2.tgz", + "integrity": "sha512-mOIv/EnPMzAZAVbuh9uGjOZ1BBdimP9Y6IPGntsvQJtko5yapSDKB7GwB3AOlF5N3bkpk4sBwQRpS3aEkiUbaA==" + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -9670,6 +9444,11 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -9839,19 +9618,6 @@ "node": ">=0.10.0" } }, - "node_modules/cmdk": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-0.2.0.tgz", - "integrity": "sha512-JQpKvEOb86SnvMZbYaFKYhvzFntWBeSZdyii0rZPhKJj9uwJBxu4DaVYDrRN7r3mPop56oPhRw+JYWTKs66TYw==", - "dependencies": { - "@radix-ui/react-dialog": "1.0.0", - "command-score": "0.1.2" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -9934,11 +9700,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/command-score": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/command-score/-/command-score-0.1.2.tgz", - "integrity": "sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==" - }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -17292,6 +17053,26 @@ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-sortablejs": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/react-sortablejs/-/react-sortablejs-6.1.4.tgz", + "integrity": "sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==", + "dependencies": { + "classnames": "2.3.1", + "tiny-invariant": "1.2.0" + }, + "peerDependencies": { + "@types/sortablejs": "1", + "react": ">=16.9.0", + "react-dom": ">=16.9.0", + "sortablejs": "1" + } + }, + "node_modules/react-sortablejs/node_modules/tiny-invariant": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", + "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -18109,6 +17890,11 @@ "tslib": "^2.0.3" } }, + "node_modules/sortablejs": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", + "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -23116,178 +22902,6 @@ "@radix-ui/react-use-controllable-state": "1.0.1" } }, - "@radix-ui/react-dialog": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.0.tgz", - "integrity": "sha512-Yn9YU+QlHYLWwV1XfKiqnGVpWYWk6MeBVM6x/bcoyPvxgjQGoeT35482viLPctTMWoMw0PoHgqfSox7Ig+957Q==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-dismissable-layer": "1.0.0", - "@radix-ui/react-focus-guards": "1.0.0", - "@radix-ui/react-focus-scope": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-portal": "1.0.0", - "@radix-ui/react-presence": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-slot": "1.0.0", - "@radix-ui/react-use-controllable-state": "1.0.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.4" - }, - "dependencies": { - "@radix-ui/primitive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", - "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-compose-refs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", - "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", - "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-dismissable-layer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.0.tgz", - "integrity": "sha512-n7kDRfx+LB1zLueRDvZ1Pd0bxdJWDUZNQ/GWoxDn2prnuJKRdxsjulejX/ePkOsLi2tTm6P24mDqlMSgQpsT6g==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-use-callback-ref": "1.0.0", - "@radix-ui/react-use-escape-keydown": "1.0.0" - } - }, - "@radix-ui/react-focus-guards": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.0.tgz", - "integrity": "sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-focus-scope": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.0.tgz", - "integrity": "sha512-C4SWtsULLGf/2L4oGeIHlvWQx7Rf+7cX/vKOAD2dXW0A1b5QXwi3wWeaEgW+wn+SEVrraMUk05vLU9fZZz5HbQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-use-callback-ref": "1.0.0" - } - }, - "@radix-ui/react-id": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", - "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.0" - } - }, - "@radix-ui/react-portal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.0.tgz", - "integrity": "sha512-a8qyFO/Xb99d8wQdu4o7qnigNjTPG123uADNecz0eX4usnQEj7o+cG4ZX4zkqq98NYekT7UoEQIjxBNWIFuqTA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.0" - } - }, - "@radix-ui/react-presence": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz", - "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-use-layout-effect": "1.0.0" - } - }, - "@radix-ui/react-primitive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz", - "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "1.0.0" - } - }, - "@radix-ui/react-slot": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz", - "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0" - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", - "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", - "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.0" - } - }, - "@radix-ui/react-use-escape-keydown": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.0.tgz", - "integrity": "sha512-JwfBCUIfhXRxKExgIqGa4CQsiMemo1Xt0W/B4ei3fpzpvPENKpMKQ8mZSB6Acj3ebrAEgi2xiQvcI1PAAodvyg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.0" - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", - "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "react-remove-scroll": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.4.tgz", - "integrity": "sha512-xGVKJJr0SJGQVirVFAUZ2k1QLyO6m+2fy0l8Qawbp5Jgrv3DeLalrfMNBFSlmz5kriGGzsVBtGVnf4pTKIhhWA==", - "requires": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - } - } - }, "@radix-ui/react-direction": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", @@ -25616,6 +25230,11 @@ "@types/node": "*" } }, + "@types/sortablejs": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.2.tgz", + "integrity": "sha512-mOIv/EnPMzAZAVbuh9uGjOZ1BBdimP9Y6IPGntsvQJtko5yapSDKB7GwB3AOlF5N3bkpk4sBwQRpS3aEkiUbaA==" + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -26968,6 +26587,11 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -27096,15 +26720,6 @@ } } }, - "cmdk": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-0.2.0.tgz", - "integrity": "sha512-JQpKvEOb86SnvMZbYaFKYhvzFntWBeSZdyii0rZPhKJj9uwJBxu4DaVYDrRN7r3mPop56oPhRw+JYWTKs66TYw==", - "requires": { - "@radix-ui/react-dialog": "1.0.0", - "command-score": "0.1.2" - } - }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -27170,11 +26785,6 @@ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==" }, - "command-score": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/command-score/-/command-score-0.1.2.tgz", - "integrity": "sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==" - }, "commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -32686,6 +32296,22 @@ "lodash": "^4.17.21" } }, + "react-sortablejs": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/react-sortablejs/-/react-sortablejs-6.1.4.tgz", + "integrity": "sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==", + "requires": { + "classnames": "2.3.1", + "tiny-invariant": "1.2.0" + }, + "dependencies": { + "tiny-invariant": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", + "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==" + } + } + }, "react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -33309,6 +32935,11 @@ "tslib": "^2.0.3" } }, + "sortablejs": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", + "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==" + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index f5b57c9b..3405508b 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,10 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.4", "@radix-ui/react-tooltip": "^1.0.6", - "cmdk": "^0.2.0", "lodash": "^4.17.21", - "react-syntax-highlighter": "^15.5.0" + "react-sortablejs": "^6.1.4", + "react-syntax-highlighter": "^15.5.0", + "sortablejs": "^1.15.0" }, "devDependencies": { "@radix-ui/react-switch": "^1.0.2", @@ -81,6 +82,7 @@ "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", "@types/react-syntax-highlighter": "^15.5.7", + "@types/sortablejs": "^1.15.2", "@types/styled-components": "^5.1.26", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 4fccbe79..e5632aaf 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -1,5 +1,7 @@ import styled from "styled-components"; -import { Icon } from "@/components"; +import { HorizontalDirection, Icon, IconName } from "@/components"; +import { HTMLAttributes, MouseEvent, ReactNode } from "react"; +import { EllipsisContainer } from "../commonElement"; export type BadgeState = | "default" | "success" @@ -11,24 +13,26 @@ export type BadgeState = export type BadgeSize = "sm" | "md"; -export interface BadgeProps { - text: string; +export interface CommonBadgeProps extends HTMLAttributes { + text: ReactNode; state?: BadgeState; size?: BadgeSize; + icon?: IconName; + iconDir?: HorizontalDirection; } -export interface DismissibleBadge extends BadgeProps { +export interface DismissibleBadge extends CommonBadgeProps { dismissible: true; - onClose: () => void; + onClose: (e: MouseEvent) => void; } -export interface NonDismissibleBadge extends BadgeProps { +export interface NonDismissibleBadge extends CommonBadgeProps { dismissible?: never; onClose?: never; } const Wrapper = styled.div<{ $state?: BadgeState; $size?: BadgeSize }>` - display: inline-block; + display: inline-flex; ${({ $state = "default", $size = "md", theme }) => ` background-color: ${theme.click.badge.color.background[$state]}; color: ${theme.click.badge.color.text[$state]}; @@ -45,29 +49,65 @@ const Content = styled.div<{ $state?: BadgeState; $size?: BadgeSize }>` gap: ${({ $size = "md", theme }) => theme.click.badge.space[$size].gap}; `; -const CrossContainer = styled.svg<{ $state?: BadgeState; $size?: BadgeSize }>` +const SvgContainer = styled.svg<{ $state?: BadgeState; $size?: BadgeSize }>` ${({ $state = "default", $size = "md", theme }) => ` color: ${theme.click.badge.color.text[$state]}; height: ${theme.click.badge.icon[$size].size.height}; width: ${theme.click.badge.icon[$size].size.width}; `} `; +const BadgeContent = styled(EllipsisContainer)<{ + $state?: BadgeState; + $size?: BadgeSize; +}>` + svg { + ${({ $state = "default", $size = "md", theme }) => ` + color: ${theme.click.badge.color.text[$state]}; + height: ${theme.click.badge.icon[$size].size.height}; + width: ${theme.click.badge.icon[$size].size.width}; + gap: inherit; + `} + } +`; + +export type BadgeProps = NonDismissibleBadge | DismissibleBadge; export const Badge = ({ + icon, + iconDir, text, state = "default", size, dismissible, onClose, -}: NonDismissibleBadge | DismissibleBadge) => ( + ...props +}: BadgeProps) => ( - {text} + {icon && iconDir === "start" && ( + + )} + {text} + {icon && iconDir === "end" && ( + + )} + {dismissible && ( - *:not(button) { + overflow: hidden; + text-overflow: ellipsis; + } +`; + +export const EllipsisContent = forwardRef>( + (props, ref) => { + return ( + { + console.log({ a: node?.scrollWidth, b: node?.clientWidth }); + if (node && node.scrollWidth > node.clientWidth) { + node.title = node.innerText; + } + }, + ])} + {...props} + /> + ); + } +); diff --git a/src/components/GenericMenu.tsx b/src/components/GenericMenu.tsx index 1f41f8d8..8d3f47a3 100644 --- a/src/components/GenericMenu.tsx +++ b/src/components/GenericMenu.tsx @@ -97,7 +97,7 @@ export const GenericMenuItem = styled.div` color:${theme.click.genericMenu.item.color.text.hover}; cursor: pointer; } - &[data-state="open"] { + &[data-state="open"], &[data-state-"checked"] { background:${theme.click.genericMenu.item.color.background.active}; color:${theme.click.genericMenu.item.color.text.active}; font: ${theme.click.genericMenu.item.typography.label.active}; @@ -114,4 +114,7 @@ export const GenericMenuItem = styled.div` &[data-state="open"] .dropdown-arrow { left: 0.5rem; } + &[hidden] { + display: none; + } `; diff --git a/src/components/IconWrapper/IconWrapper.tsx b/src/components/IconWrapper/IconWrapper.tsx new file mode 100644 index 00000000..c91aecdc --- /dev/null +++ b/src/components/IconWrapper/IconWrapper.tsx @@ -0,0 +1,55 @@ +import { ReactNode } from "react"; +import { HorizontalDirection, Icon, IconName } from "@/components"; +import { IconSize } from "@/components/Icon/types"; +import styled from "styled-components"; +import { EllipsisContent } from "../EllipsisContent/EllipsisContent"; + +const LabelContainer = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + width: -webkit-fill-available; + width: fill-available; + width: stretch; + gap: ${({ theme }) => theme.click.sidebar.navigation.item.default.space.gap}; +`; + +const IconWrapper = ({ + icon, + iconDir = "start", + size = "sm", + width, + height, + children, +}: { + icon?: IconName; + iconDir?: HorizontalDirection; + children: ReactNode; + size?: IconSize; + width?: number | string; + height?: number | string; +}) => { + return ( + + {icon && iconDir === "start" && ( + + )} + {children} + {icon && iconDir === "end" && ( + + )} + + ); +}; +export default IconWrapper; diff --git a/src/components/Select/MultiSelect.stories.tsx b/src/components/Select/MultiSelect.stories.tsx new file mode 100644 index 00000000..3c35c0aa --- /dev/null +++ b/src/components/Select/MultiSelect.stories.tsx @@ -0,0 +1,142 @@ +import { MultiSelect, MultiSelectProps } from "./MultiSelect"; +import { selectOptions } from "./selectOptions"; +import { useEffect, useState } from "react"; +interface Props extends Omit { + clickableNoData?: boolean; + value: string; + childrenType: "children" | "options"; +} +const MultiSelectExample = ({ + clickableNoData, + childrenType, + value, + ...props +}: Props) => { + const [selectedValues, setSelectedValues] = useState( + value ? value.split(",") : undefined + ); + useEffect(() => { + setSelectedValues(value ? value.split(",") : undefined); + }, [value]); + + if (childrenType === "options") { + return ( + console.log("Clicked ", search) : undefined + } + options={selectOptions} + onSelect={value => setSelectedValues(value)} + {...props} + /> + ); + } + return ( + console.log("Clicked ", search) : undefined + } + {...props} + > + + + Content0 + + +
+ Content1 long text content +
+ + Content3 +
+ ); +}; +export default { + component: MultiSelectExample, + title: "Forms/MultiSelect", + tags: ["form-field", "select", "autodocs"], + argTypes: { + label: { control: "text" }, + disabled: { control: "boolean" }, + sortable: { control: "boolean" }, + error: { control: "text" }, + value: { control: "text" }, + defaultValue: { control: "text" }, + name: { control: "text" }, + required: { control: "boolean" }, + showSearch: { control: "boolean" }, + form: { control: "text" }, + clickableNoData: { control: "boolean" }, + showCheck: { control: "boolean" }, + orientation: { control: "inline-radio", options: ["horizontal", "vertical"] }, + dir: { control: "inline-radio", options: ["start", "end"] }, + }, +}; + +export const Playground = { + args: { + label: "Label", + value: "content1", + showSearch: true, + childrenType: "children", + }, + parameters: { + docs: { + source: { + transform: (_: string, story: { args: Props; [x: string]: unknown }) => { + const { clickableNoData, childrenType, value, ...props } = story.args; + return ` console.log('Clicked ', search)}\n" + : "" + } + ${Object.entries(props) + .flatMap(([key, value]) => + typeof value === "boolean" + ? value + ? ` ${key}` + : [] + : ` ${key}=${typeof value == "string" ? `"${value}"` : `{${value}}`}` + ) + .join("\n")} +${ + childrenType === "options" + ? `options={${JSON.stringify(selectOptions, null, 2)}}\n/` + : "" +}> +${ + childrenType !== "options" + ? ` + + + + Content0 + + +
+ Content1 long text content +
+ + Content2 + + Content3 + +` + : "" +}`; + }, + }, + }, + }, +}; diff --git a/src/components/Select/MultiSelect.test.tsx b/src/components/Select/MultiSelect.test.tsx new file mode 100644 index 00000000..9b9f54f1 --- /dev/null +++ b/src/components/Select/MultiSelect.test.tsx @@ -0,0 +1,289 @@ +import { + act, + fireEvent, + queryByText as queryByTestingText, +} from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { MultiSelect, MultiSelectProps } from "./MultiSelect"; +import { ReactNode } from "react"; +import { renderCUI } from "@/utils/test-utils"; +import { selectOptions } from "./selectOptions"; +interface Props extends Omit { + nodata?: ReactNode; + showSearch?: boolean; +} +describe("MultiSelect", () => { + beforeAll(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })); + }); + const renderSelect = (props: Props) => { + if (props.options) { + return renderCUI( + + ); + } + + return renderCUI( + + + Content0 + + Content1 long text content + + Content2 + + Content3 + + + ); + }; + + it("should open select on click", () => { + const { queryByText } = renderSelect({}); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + expect(queryByText("Content0")).not.toBeNull(); + }); + + it("should always respect given value in select", () => { + const onSelect = jest.fn(); + const { queryByText, getByTestId, getByText } = renderSelect({ + value: ["content0", "content1"], + onSelect, + }); + const selectTrigger = getByTestId("select-trigger"); + expect(selectTrigger).not.toBeNull(); + expect(queryByTestingText(selectTrigger, "Content0")).not.toBeNull(); + expect( + queryByTestingText(selectTrigger, "Content1 long text content") + ).not.toBeNull(); + expect(queryByTestingText(selectTrigger, "Content3")).toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + expect(queryByText("Content3")).not.toBeNull(); + act(() => { + getByText("Content3").click(); + }); + expect(onSelect).toBeCalledTimes(1); + expect(queryByTestingText(selectTrigger, "Content3")).toBeNull(); + }); + + it("should show error", () => { + const { queryByText } = renderSelect({ + error: "Select Error", + }); + expect(queryByText("Select an option")).not.toBeNull(); + expect(queryByText("Select Error")).not.toBeNull(); + }); + + it("should not open disabled select on click", () => { + const { queryByText } = renderSelect({ + disabled: true, + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + expect(queryByText("Content0")).toBeNull(); + }); + + it("should close select on clicking outside content", () => { + const { queryByText } = renderSelect({}); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + expect(queryByText("Content0")).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + expect(queryByText("Content0")).toBeNull(); + }); + + it("should close select on selecting item", () => { + const { queryByText, getByTestId } = renderSelect({}); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + const item = queryByText("Content0"); + expect(item).not.toBeNull(); + item && fireEvent.click(item); + expect(item).not.toBeNull(); + expect(getByTestId("select-trigger")).toHaveTextContent("Content0"); + }); + + it("should not close select on selecting diabled item", () => { + const { queryByText } = renderSelect({}); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + const item = queryByText("Content4"); + expect(item).not.toBeNull(); + item && fireEvent.click(item); + expect(item).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); + }); + + it("should render options", () => { + const { queryByText, getByTestId } = renderSelect({ + options: selectOptions, + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + const item = queryByText("Content0"); + expect(item).not.toBeNull(); + item && fireEvent.click(item); + expect(item).not.toBeNull(); + expect(getByTestId("select-trigger")).toHaveTextContent("Content0"); + }); + + describe("onSearch enabled", () => { + it("on open show all options", () => { + const { queryByText } = renderSelect({ + showSearch: true, + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + expect(queryByText("Content0")).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); + expect(queryByText("Content2")).not.toBeNull(); + expect(queryByText("Content3")).not.toBeNull(); + expect(queryByText("Content4")).not.toBeNull(); + }); + + it("filter by text", () => { + const { queryByText, getByTestId } = renderSelect({ + showSearch: true, + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + expect(queryByText("Group label")).toBeVisible(); + expect(queryByText("Content0")).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); + expect(queryByText("Content2")).not.toBeNull(); + expect(queryByText("Content3")).not.toBeNull(); + expect(queryByText("Content4")).not.toBeNull(); + fireEvent.change(getByTestId("select-search-input"), { + target: { value: "content2" }, + }); + expect(queryByText("Content2")).not.toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); + expect(queryByText("Group label")).not.toBeVisible(); + }); + + it("filter by text in options", () => { + const { queryByText, getByTestId } = renderSelect({ + options: selectOptions, + showSearch: true, + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + expect(queryByText("Group label")).toBeVisible(); + expect(queryByText("Content0")).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); + expect(queryByText("Content2")).not.toBeNull(); + expect(queryByText("Content3")).not.toBeNull(); + expect(queryByText("Content4")).not.toBeNull(); + fireEvent.change(getByTestId("select-search-input"), { + target: { value: "content2" }, + }); + expect(queryByText("Content2")).not.toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); + expect(queryByText("Group label")).not.toBeVisible(); + }); + + it("on clear show all data", () => { + const { queryByText, getByTestId } = renderSelect({ + showSearch: true, + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + const selectInput = getByTestId("select-search-input"); + fireEvent.change(selectInput, { + target: { value: "content2" }, + }); + expect(queryByText("Content2")).not.toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); + expect(queryByText("Group label")).not.toBeVisible(); + fireEvent.click(getByTestId("select-search-close")); + expect(queryByText("Group label")).toBeVisible(); + expect(queryByText("Content0")).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); + expect(queryByText("Content2")).not.toBeNull(); + expect(queryByText("Content3")).not.toBeNull(); + expect(queryByText("Content4")).not.toBeNull(); + expect(document.activeElement).toBe(selectInput); + }); + it("on no options available show no data", () => { + const { queryByText, getByTestId } = renderSelect({ + showSearch: true, + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + fireEvent.change(getByTestId("select-search-input"), { + target: { value: "nodata" }, + }); + expect(queryByText("Content2")).toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); + expect(queryByText("Group label")).not.toBeVisible(); + const btn = queryByText(/No Options found/i); + expect(btn).not.toBeNull(); + btn && fireEvent.click(btn); + expect(btn).not.toBeNull(); + }); + it("on no options available show no custom data", () => { + const onClick = jest.fn(); + const { queryByText, getByTestId } = renderSelect({ + showSearch: true, + onCreateOption: onClick, + customText: "No Field found {search}", + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + fireEvent.change(getByTestId("select-search-input"), { + target: { value: "nodata" }, + }); + expect(queryByText("Content2")).toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); + expect(queryByText("Group label")).not.toBeVisible(); + const btn = queryByText(/No Field found/i); + expect(btn).not.toBeNull(); + btn && fireEvent.click(btn); + expect(onClick).toBeCalledTimes(1); + expect(getByTestId("select-trigger")).toHaveTextContent("nodata"); + }); + }); +}); diff --git a/src/components/Select/MultiSelect.tsx b/src/components/Select/MultiSelect.tsx new file mode 100644 index 00000000..0030600c --- /dev/null +++ b/src/components/Select/MultiSelect.tsx @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useState } from "react"; + +import { SelectContainerProps, SelectOptionProp } from "./common/types"; +import { SelectGroup, SelectItem, InternalSelect } from "./common/InternalSelect"; + +export interface MultiSelectProps + extends Omit< + SelectContainerProps, + "onChange" | "value" | "open" | "onOpenChange" | "onSelect" + > { + defaultValue?: Array; + onSelect?: (value: Array) => void; + value?: Array; + defaultOpen?: boolean; +} + +export const MultiSelect = ({ + value: valueProp, + defaultValue, + onSelect: onSelectProp, + options, + children, + ...props +}: MultiSelectProps) => { + const [selectedValues, setSelectedValues] = useState>( + valueProp ?? defaultValue ?? [] + ); + const [open, setOpen] = useState(false); + + useEffect(() => { + setSelectedValues(valueProp ?? []); + }, [valueProp]); + + const onChange = useCallback( + (values: Array) => { + setSelectedValues(values); + if (typeof onSelectProp === "function") { + onSelectProp(values); + } + }, + [onSelectProp] + ); + + const onSelect = useCallback( + (value: string) => { + let newValues = []; + if (selectedValues.includes(value)) { + newValues = selectedValues.filter(currentValue => currentValue !== value); + } else { + newValues = [...selectedValues, value]; + } + + onChange(newValues); + }, + [onChange, selectedValues] + ); + + const conditionalProps: Partial = {}; + if (options) { + conditionalProps.options = options; + } else { + conditionalProps.children = children; + } + + return ( + + ); +}; + +MultiSelect.Group = SelectGroup; +MultiSelect.Item = SelectItem; diff --git a/src/components/Select/MultiSelectValue.tsx b/src/components/Select/MultiSelectValue.tsx new file mode 100644 index 00000000..7fbec22c --- /dev/null +++ b/src/components/Select/MultiSelectValue.tsx @@ -0,0 +1,104 @@ +import { Badge, BadgeProps } from "@/components"; +import { DismissibleBadge, NonDismissibleBadge } from "@/components/Badge/Badge"; +import { MouseEvent, useEffect, useId, useState } from "react"; +import { ItemInterface, ReactSortable } from "react-sortablejs"; +import styled from "styled-components"; +import { SelectItemProps } from "./common/types"; + +const BadgeList = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + gap: inherit; + font: inherit; + color: inherit; +`; + +interface MultiSelectValueProps { + selectedValues: Array; + valueNode: Map; + onChange: (selectedValues: Array) => void; + onSelect: (selectedValue: string) => void; + sortable: boolean; + disabled: boolean; +} + +export const MultiSelectValue = ({ + selectedValues, + valueNode, + onChange, + sortable, + onSelect, + disabled, +}: MultiSelectValueProps) => { + const id = useId(); + const [values, setValues] = useState>( + selectedValues.map(value => ({ + id: `multi-select-${id}-${value}`, + value, + })) + ); + useEffect(() => { + setValues( + selectedValues.map(value => ({ + id: `multi-select-${id}-${value}`, + value, + })) + ); + }, [id, selectedValues]); + if (selectedValues.length === 0) { + return null; + } + + return ( + { + const { newDraggableIndex, oldDraggableIndex } = e; + if ( + typeof newDraggableIndex === "number" && + typeof oldDraggableIndex === "number" && + oldDraggableIndex !== newDraggableIndex + ) { + const temp = selectedValues[oldDraggableIndex]; + selectedValues[oldDraggableIndex] = selectedValues[newDraggableIndex]; + selectedValues[newDraggableIndex] = temp; + onChange(selectedValues); + } + }} + revertOnSpill + > + {selectedValues.map(value => { + const nodeProps = valueNode.get(value) ?? { children: value, value: value }; + let otherProps: BadgeProps = { + text: nodeProps.label ?? nodeProps.children, + icon: nodeProps?.icon, + iconDir: nodeProps?.iconDir, + } as NonDismissibleBadge; + if (!disabled && !nodeProps.disabled) { + otherProps = { + ...otherProps, + dismissible: true, + onClose: (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onSelect(nodeProps.value); + }, + } as DismissibleBadge; + } + return ( + + ); + })} + + ); +}; diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx deleted file mode 100644 index 0af1c753..00000000 --- a/src/components/Select/Select.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Icon } from "@/components"; -import { Select, SelectProps } from "./Select"; -interface Props extends SelectProps { - clickableNoData?: boolean; -} -const SelectExample = ({ clickableNoData, ...props }: Props) => { - return ( - - ); -}; -export default { - component: SelectExample, - title: "Forms/Select", - tags: ["form-field", "select", "autodocs"], - argTypes: { - label: { control: "string" }, - disabled: { control: "boolean" }, - error: { control: "string" }, - value: { control: "string" }, - defaultValue: { control: "string" }, - open: { control: "inline-radio", options: [undefined, true, false] }, - defaultOpen: { control: "boolean" }, - name: { control: "string" }, - required: { control: "boolean" }, - showSearch: { control: "boolean" }, - isFormCotrol: { control: "boolean" }, - clickableNoData: { control: "boolean" }, - }, -}; - -export const Playground = { - args: { - label: "Label", - }, -}; diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx deleted file mode 100644 index fd6b6ff7..00000000 --- a/src/components/Select/Select.tsx +++ /dev/null @@ -1,574 +0,0 @@ -import { - Children, - HTMLAttributes, - MouseEvent, - ReactElement, - ReactNode, - forwardRef, - useId, - useRef, - useState, -} from "react"; -import * as RadixPopover from "@radix-ui/react-popover"; -import { Command, useCommandState } from "cmdk"; -import { Icon, Label } from "@/components"; -import { Error, FormRoot } from "../commonElement"; -import styled from "styled-components"; -import { SelectContextProvider } from "./SelectContext"; -import Separator from "../Separator/Separator"; -import { useSelect } from "@/components/Select/useSelect"; - -interface Props extends Omit, "onChange"> { - placeholder?: string; - label?: ReactNode; - children: ReactNode; - error?: ReactNode; - showSearch?: boolean; - disabled?: boolean; - defaultValue?: string; - onChange?: (value: string) => void; - name?: string; - required?: boolean; - isFormControl?: boolean; - value?: string; -} - -declare type DivProps = HTMLAttributes; -export type SelectProps = RadixPopover.PopoverProps & Props; - -const SelectPopoverRoot = styled(RadixPopover.Root)` - width: 100%; -`; - -const SelectTrigger = styled(RadixPopover.Trigger)<{ $error: boolean }>` - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - align-items: center; - cursor: pointer; - - span:first-of-type { - max-width: 100%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - ${({ theme, $error }) => ` - border-radius: ${theme.click.field.radii.all}; - padding: ${theme.click.field.space.y} ${theme.click.field.space.x}; - gap: ${theme.click.field.space.gap}; - font: ${theme.click.field.typography.fieldText.default}; - color: ${theme.click.field.color.text.default}; - border: 1px solid ${theme.click.field.color.stroke.default}; - background: ${theme.click.field.color.background.default}; - &:hover { - border: 1px solid ${theme.click.field.color.stroke.hover}; - background: ${theme.click.field.color.background.hover}; - color: ${theme.click.field.color.text.hover}; - } - ${ - $error - ? ` - font: ${theme.click.field.typography.fieldText.error}; - border: 1px solid ${theme.click.field.color.stroke.error}; - background: ${theme.click.field.color.background.active}; - color: ${theme.click.field.color.text.error}; - &:hover { - border: 1px solid ${theme.click.field.color.stroke.error}; - color: ${theme.click.field.color.text.error}; - } - ` - : ` - &:focus, - &[data-state="open"] { - font: ${theme.click.field.typography.fieldText.active}; - border: 1px solid ${theme.click.field.color.stroke.active}; - background: ${theme.click.field.color.background.active}; - color: ${theme.click.field.color.text.active}; - & ~ label { - color: ${theme.click.field.color.label.active}; - font: ${theme.click.field.typography.label.active};; - } - } - ` - }; - &:disabled { - font: ${theme.click.field.typography.fieldText.disabled}; - border: 1px solid ${theme.click.field.color.stroke.disabled}; - background: ${theme.click.field.color.background.disabled}; - color: ${theme.click.field.color.text.disabled}; - } - `} -`; - -const SelectContent = styled(RadixPopover.Content)` - width: var(--radix-popover-trigger-width); - max-height: var(--radix-popover-content-available-height); - border-radius: 0.25rem; - - ${({ theme }) => ` - border: 1px solid ${theme.click.genericMenu.item.color.stroke.default}; - background: ${theme.click.genericMenu.item.color.background.default}; - box-shadow: 0px 1px 3px 0px rgba(16, 24, 40, 0.1), - 0px 1px 2px 0px rgba(16, 24, 40, 0.06); - border-radius: 0.25rem; - `} - overflow: hidden; - display: flex; - padding: 0.5rem 0rem; - align-items: flex-start; - gap: 0.625rem; - [cmdk-root] { - width: 100%; - } -`; - -const SearchBarContainer = styled.div<{ $showSearch: boolean }>` - width: 100%; - display: grid; - grid-template-columns: 1fr auto; - ${({ theme }) => ` - border-bottom: 1px solid ${theme.click.genericMenu.button.color.stroke.default}; - padding: ${theme.click.genericMenu.item.space.y} ${theme.click.genericMenu.item.space.x}; - color: ${theme.click.genericMenu.autocomplete.color.searchTerm.default}; - font: ${theme.click.genericMenu.autocomplete.typography.search.term.default}; - `} - ${({ $showSearch }) => - $showSearch - ? undefined - : ` - border: none; - height: 0; - padding:0; - `} -`; - -const SearchBar = styled(Command.Input)<{ $showSearch: boolean }>` - background: transparent; - border: none; - width: 100%; - outline: none; - ${({ theme, $showSearch }) => ` - min-height: ${$showSearch ? "21px" : "0"}; - gap: ${theme.click.genericMenu.item.space.gap}; - font: ${theme.click.genericMenu.item.typography.label}; - border-bottom: 1px solid ${theme.click.genericMenu.button.color.stroke.default}; - color: inherit; - font: inherit; - &::placeholder { - color: ${theme.click.genericMenu.autocomplete.color.placeholder.default}; - font: ${theme.click.genericMenu.autocomplete.typography.search.placeholder.default}; - } - ${ - $showSearch - ? undefined - : ` - height: 0; - opacity: 0; - ` - } - `} -`; - -const SearchClose = styled.button` - background: transparent; - border: none; - padding: 0; - outline: none; - cursor: pointer; - color: inherit; -`; - -const NoDataContainer = styled.button<{ $clickable: boolean }>` - border: none; - display: flex; - justify-content: flex-start; - width: 100%; - ${({ theme, $clickable }) => ` - font: ${theme.click.genericMenu.button.typography.label.default} - padding: ${theme.click.genericMenu.button.space.y} ${ - theme.click.genericMenu.item.space.x - }; - background: ${theme.click.genericMenu.button.color.background.default}; - color: ${theme.click.genericMenu.button.color.label.default}; - &:hover { - font: ${theme.click.genericMenu.button.typography.label.hover}; - } - cursor: ${$clickable ? "pointer" : "default"} - `} -`; -declare type State = { - search: string; - value: string; - filtered: { - count: number; - items: Map; - groups: Set; - }; -}; - -interface SelectRootProps { - open?: boolean; - id: string; - placeholder: string; - disabled?: boolean; - children: ReactNode; - hasError: boolean; - showSearch?: boolean; -} - -const SelectRoot = ({ - open, - id, - placeholder, - disabled, - children, - hasError, - showSearch = false, -}: SelectRootProps) => { - const { valueNode, popperOpen, onOpenChange } = useSelect(); - const inputRef = useRef(null); - const [search, setSearch] = useState(""); - const onFocus = () => { - inputRef.current?.focus(); - }; - - const clearSearch = () => { - setSearch(""); - }; - - return ( - - - {valueNode ?? placeholder} - - - - - - - - {search.length > 0 && ( - - - - )} - - {children} - - - - - ); -}; - -const findChildWithSpecificProp = - (children: ReactNode): ((value?: string) => ReactElement | null) => - (value?: string): ReactElement | null => { - if (!value) { - return null; - } - - let foundChild: ReactNode | null = null; - - Children.forEach(children, (child: ReactNode) => { - const childProps = - child && typeof child === "object" && "props" in child ? child.props : null; - if (childProps?.value === value) { - foundChild = child; - return; // Break the loop if the child is found - } - - if (childProps?.children) { - const nestedChild = findChildWithSpecificProp(childProps.children)(value); - if (nestedChild) { - foundChild = nestedChild; - return; // Break the loop if the nested child is found - } - } - }); - - return foundChild; - }; - -export const Select = ({ - placeholder = "Select an option", - label, - children, - disabled, - id, - error, - value, - defaultValue, - onChange, - open, - defaultOpen, - onOpenChange, - name, - required, - isFormControl, - showSearch = false, - ...props -}: SelectProps) => { - const defaultId = useId(); - return ( - - {error && {error}} - {isFormControl && ( - - )} - - - {children} - - - {label && ( - - )} - - ); -}; -interface GroupProps extends Omit { - heading?: ReactNode; - value?: string; -} - -const SelectGroup = styled(Command.Group)` - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - width: var(--radix-popover-trigger-width); - padding: 0; - gap: 0.5rem; - &[aria-selected] { - outline: none; - } - - ${({ theme }) => ` - font: ${theme.click.genericMenu.item.typography.label.default}; - background: ${theme.click.genericMenu.item.color.background.default}; - color: ${theme.click.genericMenu.item.color.text.default}; - &[data-highlighted] { - font: ${theme.click.genericMenu.item.typography.label.hover}; - background: ${theme.click.genericMenu.item.color.background.hover}; - color:${theme.click.genericMenu.item.color.text.hover}; - } - &[data-state="checked"] { - background:${theme.click.genericMenu.item.color.background.active}; - color:${theme.click.genericMenu.item.color.text.active}; - font: ${theme.click.genericMenu.item.typography.label.active}; - } - &[data-disabled] { - background:${theme.click.genericMenu.item.color.background.disabled}; - color:${theme.click.genericMenu.item.color.text.disabled}; - font: ${theme.click.genericMenu.item.typography.label.disabled}; - pointer-events: none; - } - `}; - [cmdk-group-heading] { - display: flex; - width: 100%; - flex-direction: column; - max-width: calc(var(--radix-popover-trigger-width) - 24px); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - ${({ theme }) => ` - font: ${theme.click.genericMenu.item.typography.sectionHeader.default}; - color: ${theme.click.genericMenu.item.color.text.muted}; - padding: ${theme.click.genericMenu.item.space.y} ${theme.click.genericMenu.item.space.x}; - gap: ${theme.click.genericMenu.item.space.gap}; - border-bottom: 1px solid ${theme.click.genericMenu.item.color.stroke.default}; - `} - } - [cmdk-group-items] { - width: 100%; - } - &[hidden] { - display: none; - [cmdk-group-heading] { - display: none; - } - } -`; - -const Group = forwardRef( - ({ children, ...props }, forwardedRef) => { - return ( - - {children} - - ); - } -); -Group.displayName = "Select.Group"; - -const SelectItem = styled(Command.Item)` - display: flex; - width: 100%; - align-items: center; - cursor: default; - max-width: calc(var(--radix-popover-trigger-width) - 24px); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - &[aria-selected] { - outline: none; - } - - ${({ theme }) => ` - padding: ${theme.click.genericMenu.item.space.y} ${theme.click.genericMenu.item.space.x}; - gap: ${theme.click.genericMenu.item.space.gap}; - font: ${theme.click.genericMenu.item.typography.label.default}; - background: ${theme.click.genericMenu.item.color.background.default}; - color: ${theme.click.genericMenu.item.color.text.default}; - &[data-selected="true"] { - font: ${theme.click.genericMenu.item.typography.label.hover}; - background: ${theme.click.genericMenu.item.color.background.hover}; - color:${theme.click.genericMenu.item.color.text.hover}; - cursor: pointer; - } - &[data-state="checked"] { - background:${theme.click.genericMenu.item.color.background.active}; - color:${theme.click.genericMenu.item.color.text.active}; - font: ${theme.click.genericMenu.item.typography.label.active}; - } - &[data-disabled] { - background:${theme.click.genericMenu.item.color.background.disabled}; - color:${theme.click.genericMenu.item.color.text.disabled}; - font: ${theme.click.genericMenu.item.typography.label.disabled}; - pointer-events: none; - } - `}; -`; - -interface ItemProps extends Omit { - separator?: boolean; - disabled?: boolean; - onSelect?: (value: string) => void; - value?: string; -} - -const Item = forwardRef( - ( - { children, separator, onSelect: onSelectProp, value = "", ...props }, - forwardedRef - ) => { - const { selectedValue, onSelect } = useSelect(); - const onSelectValue = () => { - onSelect(value); - if (typeof onSelectProp == "function") { - onSelectProp(value); - } - }; - return ( - <> - - {children} - - {separator && } - - ); - } -); -Item.displayName = "Select.Item"; - -Select.Group = Group; -Select.Item = Item; - -type SelectNoDataProps = Omit, "children"> & { - children?: (props: { search: string }) => ReactNode; -}; -const SelectNoData = ({ children, onClick, ...props }: SelectNoDataProps): ReactNode => { - const clickable = typeof onClick === "function"; - const search = useCommandState((state: State) => state.search); - const { onOpenChange } = useSelect(); - const onSelect = (e: MouseEvent) => { - e.preventDefault(); - if (clickable) { - onClick(e); - onOpenChange(false); - } - }; - return ( - - - {typeof children === "function" - ? children({ search }) - : ` - No Options found${search.length > 0 ? ` for "${search}" ` : ""} - `} - - - ); -}; - -SelectNoData.displayName = "SelectNoData"; -Select.NoData = SelectNoData; diff --git a/src/components/Select/SelectContext.tsx b/src/components/Select/SelectContext.tsx deleted file mode 100644 index 1a38701a..00000000 --- a/src/components/Select/SelectContext.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { - createContext, - HTMLAttributes, - ReactElement, - ReactNode, - useEffect, - useState, -} from "react"; -import styled from "styled-components"; - -const SelectValueContainer = styled.div` - display: flex; - width: 100%; - align-items: center; - cursor: default; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - &[aria-selected] { - outline: none; - } - - ${({ theme }) => ` - gap: ${theme.click.field.space.gap}; - font: ${theme.click.field.typography.fieldText.default}; - color: ${theme.click.field.color.text.default}; - &[data-selected="true"] { - font: ${theme.click.field.typography.fieldText.hover}; - color:${theme.click.field.color.text.hover}; - cursor: pointer; - } - &[data-state="checked"] { - color:${theme.click.field.color.text.active}; - font: ${theme.click.field.typography.fieldText.active}; - } - &[data-disabled] { - color:${theme.click.field.color.text.disabled}; - font: ${theme.click.field.typography.fieldText.disabled}; - pointer-events: none; - } - `}; -`; -const SelectValue = (props: HTMLAttributes) => ( - -); - -type ContextProps = { - selectedValue?: string | null; - onSelect: (value: string) => void; - popperOpen: boolean; - valueNode: ReactNode | null; - onOpenChange: (value: boolean) => void; -}; - -export const SelectContext = createContext({ - selectedValue: undefined, - onSelect: () => null, - popperOpen: false, - valueNode: null, - onOpenChange: () => null, -}); - -type Props = { - children: ReactNode; - value?: string; - defaultValue?: string; - updateValueNode: (value?: string) => ReactElement | null; - defaultOpen?: boolean; - onOpenChange?: (value: boolean) => void; - onChange?: (value: string) => void; -}; - -export const SelectProvider = ({ - value, - children, -}: { - children: ReactNode; - value: ContextProps; -}) => { - return {children}; -}; - -export const SelectContextProvider = ({ - children, - value, - defaultValue, - updateValueNode, - defaultOpen = false, - onOpenChange: onOpenChangeProp, - onChange, -}: Props) => { - const [popperOpen, setPopperOpen] = useState(defaultOpen); - const [valueNode, setValueNode] = useState(null); - const [selectedValue, setSelectedValue] = useState( - value ?? defaultValue - ); - - const onSelect = (value: string) => { - setSelectedValue(value); - onOpenChange(false); - if (typeof onChange === "function") { - onChange(value); - } - }; - - const onOpenChange = (open: boolean) => { - setPopperOpen(open); - if (typeof onOpenChangeProp === "function") { - onOpenChangeProp(open); - } - }; - - const selectValue = { - selectedValue, - valueNode, - onSelect, - popperOpen, - onOpenChange, - }; - - useEffect(() => { - const element = updateValueNode(selectedValue); - setValueNode(element ? : null); - }, [selectedValue, updateValueNode]); - - return {children}; -}; diff --git a/src/components/Select/SingleSelect.stories.tsx b/src/components/Select/SingleSelect.stories.tsx new file mode 100644 index 00000000..4a0778d8 --- /dev/null +++ b/src/components/Select/SingleSelect.stories.tsx @@ -0,0 +1,138 @@ +import { Select, SelectProps } from "./SingleSelect"; +import { Preview } from "@storybook/react"; +import { selectOptions } from "./selectOptions"; +import { useEffect, useState } from "react"; +interface Props extends SelectProps { + clickableNoData?: boolean; + childrenType: "children" | "options"; +} +const SelectExample = ({ clickableNoData, childrenType, value, ...props }: Props) => { + const [selectedValue, setSelectedValue] = useState(value); + useEffect(() => { + setSelectedValue(value); + }, [value]); + + if (childrenType === "options") { + return ( + console.log("Clicked ", search) : undefined + } + {...props} + > + + + Content0 + + +
+ Content1 long text content +
+ + Content2 + + Content3 + + + ); +}; + +export default { + component: SelectExample, + title: "Forms/Select", + tags: ["form-field", "select", "autodocs"], + argTypes: { + label: { control: "text" }, + disabled: { control: "boolean" }, + error: { control: "text" }, + value: { control: "text" }, + defaultValue: { control: "text" }, + name: { control: "text" }, + required: { control: "boolean" }, + showSearch: { control: "boolean" }, + form: { control: "text" }, + clickableNoData: { control: "boolean" }, + orientation: { control: "inline-radio", options: ["horizontal", "vertical"] }, + dir: { control: "inline-radio", options: ["start", "end"] }, + childrenType: { control: "inline-radio", options: ["children", "options"] }, + }, +}; + +export const Playground: Preview = { + args: { + label: "Label", + childrenType: "children", + }, + parameters: { + docs: { + source: { + transform: (_: string, story: { args: Props; [x: string]: unknown }) => { + const { clickableNoData, childrenType, value, ...props } = story.args; + return ` console.log('Clicked ', search)}\n" + : "" + }${Object.entries(props) + .flatMap(([key, value]) => + typeof value === "boolean" + ? value + ? ` ${key}` + : [] + : ` ${key}=${typeof value == "string" ? `"${value}"` : `{${value}}`}` + ) + .join("\n")} +${ + childrenType === "options" + ? `options={${JSON.stringify(selectOptions, null, 2)}}\n/` + : "" +}> +${ + childrenType !== "options" + ? ` + + + + Content0 + + +
+ Content1 long text content +
+ + Content2 + + Content3 + +` + : "" +}`; + }, + }, + }, + }, +}; diff --git a/src/components/Select/Select.test.tsx b/src/components/Select/SingleSelect.test.tsx similarity index 62% rename from src/components/Select/Select.test.tsx rename to src/components/Select/SingleSelect.test.tsx index 015ec1e4..50bbaecc 100644 --- a/src/components/Select/Select.test.tsx +++ b/src/components/Select/SingleSelect.test.tsx @@ -1,10 +1,16 @@ -import { fireEvent } from "@testing-library/react"; +import { + act, + fireEvent, + queryByText as queryByTestingText, +} from "@testing-library/react"; import "@testing-library/jest-dom"; -import { Select, SelectProps } from "./Select"; +import { Select, SelectProps } from "./SingleSelect"; import { ReactNode } from "react"; import { renderCUI } from "@/utils/test-utils"; +import { selectOptions } from "./selectOptions"; interface Props extends Omit { nodata?: ReactNode; + showSearch?: boolean; } describe("Select", () => { beforeAll(() => { @@ -15,8 +21,17 @@ describe("Select", () => { disconnect: jest.fn(), })); }); - const renderSelect = ({ nodata, ...props }: Props) => - renderCUI( + const renderSelect = (props: Props) => { + if (props.options) { + return renderCUI( + { Content0 - Content1 - Content2 - Content3 + Content1 long text content - Content4 + Content2 - {nodata ? nodata : } + Content3 + ); + }; it("should open select on click", () => { const { queryByText } = renderSelect({}); @@ -76,6 +94,43 @@ describe("Select", () => { expect(queryByText("Content0")).toBeNull(); }); + it("should always respect given value in select", () => { + const onSelect = jest.fn(); + const { queryByText, getByTestId, getByText } = renderSelect({ + value: "content0", + onSelect, + }); + const selectTrigger = getByTestId("select-trigger"); + expect(selectTrigger).not.toBeNull(); + expect(queryByTestingText(selectTrigger, "Content0")).not.toBeNull(); + expect(queryByTestingText(selectTrigger, "Content3")).toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + expect(queryByText("Content3")).not.toBeNull(); + act(() => { + getByText("Content3").click(); + }); + expect(onSelect).toBeCalledTimes(1); + expect(queryByText("Content4")).toBeNull(); + expect(queryByTestingText(selectTrigger, "Content3")).toBeNull(); + expect(queryByTestingText(selectTrigger, "Content0")).not.toBeNull(); + }); + + it("should render options", () => { + const { queryByText, getByTestId } = renderSelect({ + options: selectOptions, + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + const item = queryByText("Content0"); + expect(item).not.toBeNull(); + item && fireEvent.click(item); + expect(item).not.toBeNull(); + expect(getByTestId("select-trigger")).toHaveTextContent("Content0"); + }); + it("should close select on selecting item", () => { const { queryByText } = renderSelect({}); const selectTrigger = queryByText("Select an option"); @@ -86,20 +141,20 @@ describe("Select", () => { expect(item).not.toBeNull(); item && fireEvent.click(item); expect(item).not.toBeNull(); - expect(queryByText("Content1")).toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); }); - it("should close select on selecting diabled item", () => { + it("should not close select on selecting diabled item", () => { const { queryByText } = renderSelect({}); const selectTrigger = queryByText("Select an option"); expect(selectTrigger).not.toBeNull(); selectTrigger && fireEvent.click(selectTrigger); - const item = queryByText("Content4"); + const item = queryByText("Content2"); expect(item).not.toBeNull(); item && fireEvent.click(item); expect(item).not.toBeNull(); - expect(queryByText("Content1")).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); }); describe("onSearch enabled", () => { @@ -112,7 +167,7 @@ describe("Select", () => { selectTrigger && fireEvent.click(selectTrigger); expect(queryByText("Content0")).not.toBeNull(); - expect(queryByText("Content1")).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); expect(queryByText("Content2")).not.toBeNull(); expect(queryByText("Content3")).not.toBeNull(); expect(queryByText("Content4")).not.toBeNull(); @@ -128,15 +183,38 @@ describe("Select", () => { expect(queryByText("Group label")).toBeVisible(); expect(queryByText("Content0")).not.toBeNull(); - expect(queryByText("Content1")).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); expect(queryByText("Content2")).not.toBeNull(); expect(queryByText("Content3")).not.toBeNull(); expect(queryByText("Content4")).not.toBeNull(); fireEvent.change(getByTestId("select-search-input"), { - target: { value: "content2" }, + target: { value: "content3" }, }); + expect(queryByText("Content3")).not.toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); + expect(queryByText("Group label")).not.toBeVisible(); + }); + + it("filter by text in options", () => { + const { queryByText, getByTestId } = renderSelect({ + options: selectOptions, + showSearch: true, + }); + const selectTrigger = queryByText("Select an option"); + expect(selectTrigger).not.toBeNull(); + selectTrigger && fireEvent.click(selectTrigger); + + expect(queryByText("Group label")).toBeVisible(); + expect(queryByText("Content0")).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); expect(queryByText("Content2")).not.toBeNull(); - expect(queryByText("Content1")).toBeNull(); + expect(queryByText("Content3")).not.toBeNull(); + expect(queryByText("Content4")).not.toBeNull(); + fireEvent.change(getByTestId("select-search-input"), { + target: { value: "content3" }, + }); + expect(queryByText("Content3")).not.toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); expect(queryByText("Group label")).not.toBeVisible(); }); @@ -150,15 +228,15 @@ describe("Select", () => { const selectInput = getByTestId("select-search-input"); fireEvent.change(selectInput, { - target: { value: "content2" }, + target: { value: "content3" }, }); - expect(queryByText("Content2")).not.toBeNull(); - expect(queryByText("Content1")).toBeNull(); + expect(queryByText("Content3")).not.toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); expect(queryByText("Group label")).not.toBeVisible(); fireEvent.click(getByTestId("select-search-close")); expect(queryByText("Group label")).toBeVisible(); expect(queryByText("Content0")).not.toBeNull(); - expect(queryByText("Content1")).not.toBeNull(); + expect(queryByText("Content1 long text content")).not.toBeNull(); expect(queryByText("Content2")).not.toBeNull(); expect(queryByText("Content3")).not.toBeNull(); expect(queryByText("Content4")).not.toBeNull(); @@ -176,7 +254,7 @@ describe("Select", () => { target: { value: "nodata" }, }); expect(queryByText("Content2")).toBeNull(); - expect(queryByText("Content1")).toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); expect(queryByText("Group label")).not.toBeVisible(); const btn = queryByText(/No Options found/i); expect(btn).not.toBeNull(); @@ -187,11 +265,8 @@ describe("Select", () => { const onClick = jest.fn(); const { queryByText, getByTestId } = renderSelect({ showSearch: true, - nodata: ( - - {({ search }) => `No Field found ${search}`} - - ), + onCreateOption: onClick, + customText: "No Field found {search}", }); const selectTrigger = queryByText("Select an option"); expect(selectTrigger).not.toBeNull(); @@ -201,7 +276,7 @@ describe("Select", () => { target: { value: "nodata" }, }); expect(queryByText("Content2")).toBeNull(); - expect(queryByText("Content1")).toBeNull(); + expect(queryByText("Content1 long text content")).toBeNull(); expect(queryByText("Group label")).not.toBeVisible(); const btn = queryByText(/No Field found/i); expect(btn).not.toBeNull(); diff --git a/src/components/Select/SingleSelect.tsx b/src/components/Select/SingleSelect.tsx new file mode 100644 index 00000000..596f6c6c --- /dev/null +++ b/src/components/Select/SingleSelect.tsx @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useState } from "react"; +import { SelectContainerProps, SelectOptionProp } from "./common/types"; +import { InternalSelect, SelectGroup, SelectItem } from "./common/InternalSelect"; + +export interface SelectProps + extends Omit< + SelectContainerProps, + "onChange" | "value" | "sortable" | "open" | "onOpenChange" | "onSelect" + > { + defaultValue?: string; + onSelect?: (value: string) => void; + value?: string; + placeholder?: string; +} + +export const Select = ({ + value: valueProp, + defaultValue, + onSelect: onSelectProp, + options, + children, + ...props +}: SelectProps) => { + const [selectedValues, setSelectedValues] = useState>( + valueProp ? [valueProp] : defaultValue ? [defaultValue] : [] + ); + const [open, setOpen] = useState(false); + + const onOpenChange = useCallback((open: boolean) => { + setOpen(open); + }, []); + + const onSelect = useCallback( + (value: string) => { + setSelectedValues(values => { + if (values[0] !== value) { + return [value]; + } + return values; + }); + onOpenChange(false); + if (typeof onSelectProp === "function") { + onSelectProp(value); + } + }, + [onSelectProp, onOpenChange] + ); + + const onChange = useCallback( + (values: Array) => { + if (values[0] !== selectedValues[0]) { + onSelect(values[0]); + } + }, + [selectedValues, onSelect] + ); + + useEffect(() => { + setSelectedValues(valueProp ? [valueProp] : []); + }, [valueProp]); + + const conditionalProps: Partial = {}; + if (options) { + conditionalProps.options = options; + } else { + conditionalProps.children = children; + } + + return ( + + ); +}; + +Select.Group = SelectGroup; +Select.Item = SelectItem; diff --git a/src/components/Select/SingleSelectValue.tsx b/src/components/Select/SingleSelectValue.tsx new file mode 100644 index 00000000..b555a3a8 --- /dev/null +++ b/src/components/Select/SingleSelectValue.tsx @@ -0,0 +1,40 @@ +import styled from "styled-components"; +import { SelectItemProps } from "./common/types"; +import IconWrapper from "../IconWrapper/IconWrapper"; + +const SelectValueContainer = styled.div` + display: flex; + width: 100%; + align-items: center; + cursor: inherit; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + pointer-events: inherit; +`; + +const SingleSelectValue = ({ + valueNode, + value, +}: { + valueNode?: SelectItemProps; + value: string; +}) => { + const { icon, iconDir, children, label } = valueNode ?? {}; + if (!value) { + return null; + } + + return ( + + + {label ?? children ?? value} + + + ); +}; + +export default SingleSelectValue; diff --git a/src/components/Select/common/InternalSelect.tsx b/src/components/Select/common/InternalSelect.tsx new file mode 100644 index 00000000..50cb8e0d --- /dev/null +++ b/src/components/Select/common/InternalSelect.tsx @@ -0,0 +1,587 @@ +import { + Children, + FunctionComponent, + KeyboardEvent, + MouseEvent, + ReactNode, + forwardRef, + isValidElement, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { + SelectContainerProps, + SelectGroupProps, + SelectItemObject, + SelectItemProps, + SelectOptionListItem, +} from "./types"; +import { Error, FormElementContainer, FormRoot } from "@/components/commonElement"; +import { Portal } from "@radix-ui/react-popover"; +import { Icon, IconButton, Label, Separator } from "@/components"; +import { + SelectPopoverContent, + SearchBar, + SearchBarContainer, + SearchClose, + SelectList, + SelectListContent, + SelectPopoverRoot, + StyledSelectTrigger, + SelectValue, + HiddenSelectElement, + SelectGroupContainer, + SelectGroupName, + SelectGroupContent, + SelectNoDataContainer, +} from "./SelectStyled"; +import { OptionContext } from "./OptionContext"; +import { MultiSelectValue } from "../MultiSelectValue"; +import SingleSelectValue from "../SingleSelectValue"; +import { useOption, useSearch } from "./useOption"; +import { mergeRefs } from "@/utils/mergeRefs"; +import { GenericMenuItem } from "@/components/GenericMenu"; +import IconWrapper from "@/components/IconWrapper/IconWrapper"; + +type CallbackProps = SelectItemObject & { + nodeProps: SelectItemProps; +}; + +const getTextFromNodes = (node: ReactNode): string => { + if (node === null) { + return ""; + } + + if (typeof node === "string") { + return node; + } + + if (Array.isArray(node)) { + return node.map(getTextFromNodes).join(" "); + } + + if (isValidElement(node)) { + const children = Children.toArray(node.props.children); + return children.map(getTextFromNodes).join(" "); + } + + return ""; +}; + +const childrenToComboboxItemArray = ( + children: ReactNode, + callback: (props: CallbackProps) => void, + heading?: string +): Array => { + return Children.toArray(children).flatMap(child => { + if (isValidElement(child) && child && typeof child === "object") { + const type = child.type as FunctionComponent; + if (type.displayName === "Select.Group") { + const groupChildren = child.props.children; + return childrenToComboboxItemArray( + groupChildren, + callback, + getTextFromNodes(child.props.heading).toLowerCase() + ); + } else if (type.displayName === "Select.Item") { + const title = getTextFromNodes(child).toLowerCase(); + const value = child.props.value; + const disabled = child.props.disabled; + callback({ + disabled, + value, + title, + heading, + nodeProps: child.props, + }); + return { + disabled, + value, + title, + heading, + }; + } else if ("props" in child && child.props.children) { + return childrenToComboboxItemArray(child.props.children, callback, heading); + } + } + return []; + }); +}; + +export const InternalSelect = ({ + label, + children, + orientation, + dir, + disabled, + id, + error, + value: selectedValues, + onChange, + onSelect, + open, + onOpenChange, + name, + form, + onCreateOption: onCreateOptionProp, + customText = "", + options, + showCheck, + sortable = false, + placeholder = "Select an option", + multiple, + showSearch = false, + ...props +}: SelectContainerProps) => { + const defaultId = useId(); + const [search, setSearch] = useState(""); + const [highlighted, setHighlighted] = useState(); + const visibleList = useRef>([]); + const navigatable = useRef>([]); + const valueNode = useRef>(new Map()); + const [list, setList] = useState>([]); + const updateElements = useCallback( + ({ disabled, value, title, heading, nodeProps }: CallbackProps) => { + if (title.includes(search) || heading?.includes(search)) { + visibleList.current.push(value); + if (!disabled) { + navigatable.current.push(value); + } + } + valueNode.current.set(value, nodeProps); + }, + [search] + ); + const onUpdateSearch = useCallback( + (search: string) => { + setSearch(search); + let hasHighlightedValue = false; + const visibleItemsList: Array = []; + const navigatableList: Array = []; + const searchLowerCase = search.toLowerCase(); + list.forEach(item => { + if ( + item.title.includes(searchLowerCase) || + item.heading?.includes(searchLowerCase) + ) { + if (item.value === highlighted) { + hasHighlightedValue = true; + } + visibleItemsList.push(item.value); + if (!item.disabled) { + navigatableList.push(item.value); + } + } + }); + navigatable.current = navigatableList; + visibleList.current = visibleItemsList; + if (!hasHighlightedValue) { + setHighlighted(navigatableList[0] ?? null); + } + }, + [highlighted, list] + ); + + const updateList = useCallback( + (children?: ReactNode, options?: Array) => { + const lowerCasedSearch = search.toLowerCase(); + if (options) { + setList( + options.flatMap(option => { + if ("options" in option) { + const heading = getTextFromNodes(option.heading).toLowerCase(); + return (option.options ?? []).map(item => { + valueNode.current.set(item.value, item); + const title = getTextFromNodes(item.label).toLowerCase(); + if ( + title.includes(lowerCasedSearch) || + heading?.includes(lowerCasedSearch) + ) { + visibleList.current.push(item.value); + if (!disabled) { + navigatable.current.push(item.value); + } + } + return { + heading, + disabled: item.disabled, + value: item.value, + title, + }; + }); + } else { + valueNode.current.set(option.value, option); + const title = getTextFromNodes(option.label).toLowerCase(); + if (title.includes(lowerCasedSearch)) { + visibleList.current.push(option.value); + if (!disabled) { + navigatable.current.push(option.value); + } + } + return { + disabled: option.disabled, + value: option.value, + title: getTextFromNodes(option.label), + }; + } + }) + ); + } else if (children) { + setList(childrenToComboboxItemArray(children, updateElements)); + } + }, + [disabled, search, updateElements] + ); + + useEffect(() => { + updateList(children, options); + }, [children, options, updateList]); + + const inputRef = useRef(null); + + const onFocus = () => { + inputRef.current?.focus(); + }; + + const clearSearch = () => { + onUpdateSearch(""); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (!e.defaultPrevented) { + if (e.key === "Enter") { + e.preventDefault(); + if (highlighted) { + onSelect(highlighted); + } else if ( + visibleList.current.length === 0 && + typeof onCreateOptionProp === "function" + ) { + onCreateOptionProp(search); + onSelect(search); + } + } else if (["ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) { + e.preventDefault(); + let nextHighlightedValue = highlighted; + const highlightedIndex = navigatable.current.findIndex( + value => value === highlighted + ); + if (e.key === "ArrowUp") { + if (highlightedIndex === 0) { + nextHighlightedValue = navigatable.current[navigatable.current.length - 1]; + } else { + nextHighlightedValue = navigatable.current[highlightedIndex - 1]; + } + } else if (e.key === "ArrowDown") { + e.preventDefault(); + if (highlightedIndex === navigatable.current.length - 1) { + nextHighlightedValue = navigatable.current[0]; + } else { + nextHighlightedValue = navigatable.current[highlightedIndex + 1]; + } + } else if (e.key === "End") { + e.preventDefault(); + nextHighlightedValue = navigatable.current[navigatable.current.length - 1]; + } else if (e.key === "Home") { + nextHighlightedValue = navigatable.current[0]; + e.preventDefault(); + } + setHighlighted(nextHighlightedValue); + } + } + }; + const isHidden = useCallback( + (value?: string) => { + return !visibleList.current.includes(value ?? ""); + }, + [visibleList] + ); + + const optionContextValue = useMemo(() => { + return { + search, + updateHighlighted: setHighlighted, + highlighted, + isHidden, + onSelect, + showCheck, + selectedValues, + }; + }, [search, highlighted, isHidden, onSelect, showCheck, selectedValues]); + + const clickable = typeof onCreateOptionProp === "function"; + const onCreateOption = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (clickable) { + onCreateOptionProp(search); + onSelect(search); + } + }; + return ( + + + + + + {selectedValues.length === 0 ? ( + placeholder + ) : multiple ? ( + + ) : ( + + )} + + + + {form && ( + null} + > + {list.map(item => ( + + ))} + + )} + + { + onUpdateSearch(""); + }} + onOpenAutoFocus={() => { + setHighlighted(visibleList.current[0]); + }} + align="start" + > + + + onUpdateSearch(e.target.value)} + data-testid="select-search-input" + onKeyDown={onKeyDown} + $showSearch={showSearch} + /> + 0} + size="xs" + /> + + + + {options && options.length > 0 + ? options.map((props, index) => { + if ("options" in props) { + const { options: itemList = [], ...groupProps } = props; + return ( + + {itemList.map(({ label, ...itemProps }, itemIndex) => ( + + {label} + + ))} + + ); + } else { + return ( + + ); + } + }) + : children} + + + {visibleList.current.length === 0 && ( + + {customText.length > 0 + ? customText.replaceAll("{search}", search) + : ` + No Options found${search.length > 0 ? ` for "${search}" ` : ""} + `} + + )} + + + + + {!!error && {error}} + + {label && ( + + )} + + ); +}; + +export const SelectGroup = forwardRef( + ({ children, heading, ...props }, forwardedRef) => { + useSearch(); + return ( + { + const hidden = node?.querySelectorAll("[cui-select-item]").length === 0; + if (hidden) { + node?.setAttribute("hidden", ""); + } else { + node?.removeAttribute("hidden"); + } + node?.setAttribute("aria-hidden", hidden.toString()); + }, + ])} + > + {heading} + {children} + + ); + } +); + +SelectGroup.displayName = "Select.Group"; + +export const SelectItem = forwardRef( + ( + { + disabled = false, + children, + label, + separator, + onSelect: onSelectProp, + value = "", + icon, + iconDir, + onMouseOver: onMouseOverProp, + ...props + }, + forwardedRef + ) => { + const { + highlighted, + updateHighlighted, + isHidden, + selectedValues, + onSelect, + showCheck, + } = useOption(); + const onSelectValue = () => { + if (!disabled) { + onSelect(value); + if (typeof onSelectProp == "function") { + onSelectProp(value); + } + } + }; + const onMouseOver = (e: MouseEvent) => { + if (onMouseOverProp) { + onMouseOverProp(e); + } + if (!disabled) { + updateHighlighted(value); + } + }; + + if (isHidden(value)) { + return null; + } + const isChecked = selectedValues.includes(value); + + return ( + <> + + + {label ?? children} + + {showCheck && isChecked && ( + + )} + + {separator && } + + ); + } +); + +SelectItem.displayName = "Select.Item"; diff --git a/src/components/Select/common/OptionContext.ts b/src/components/Select/common/OptionContext.ts new file mode 100644 index 00000000..b2dc6dc3 --- /dev/null +++ b/src/components/Select/common/OptionContext.ts @@ -0,0 +1,20 @@ +import { createContext } from "react"; + +type OptionContextProps = { + search: string; + highlighted?: string; + updateHighlighted: (value: string) => void; + isHidden: (value?: string) => boolean; + selectedValues: Array; + onSelect: (value: string) => void; + onCreateOption?: (search: string) => void; + showCheck?: boolean; +}; + +export const OptionContext = createContext({ + search: "", + selectedValues: [], + updateHighlighted: () => null, + onSelect: () => null, + isHidden: () => true, +}); diff --git a/src/components/Select/common/SelectStyled.tsx b/src/components/Select/common/SelectStyled.tsx new file mode 100644 index 00000000..adc25912 --- /dev/null +++ b/src/components/Select/common/SelectStyled.tsx @@ -0,0 +1,237 @@ +import { Content, Root, Trigger } from "@radix-ui/react-popover"; +import styled from "styled-components"; + +export const SelectPopoverRoot = styled(Root)` + width: 100%; +`; + +export const SelectValue = styled.div` + display: flex; + flex-direction: column; + text-align: left; + flex: 1; + gap: inherit; + color: inherit; + font: inherit; +`; + +export const StyledSelectTrigger = styled(Trigger)<{ $error: boolean }>` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + align-items: center; + cursor: pointer; + + span:first-of-type { + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + ${({ theme, $error }) => ` + border-radius: ${theme.click.field.radii.all}; + padding: ${theme.click.field.space.y} ${theme.click.field.space.x}; + gap: ${theme.click.field.space.gap}; + font: ${theme.click.field.typography.fieldText.default}; + color: ${theme.click.field.color.text.default}; + border: 1px solid ${theme.click.field.color.stroke.default}; + background: ${theme.click.field.color.background.default}; + &:hover { + border: 1px solid ${theme.click.field.color.stroke.hover}; + background: ${theme.click.field.color.background.hover}; + color: ${theme.click.field.color.text.hover}; + } + ${ + $error + ? ` + font: ${theme.click.field.typography.fieldText.error}; + border: 1px solid ${theme.click.field.color.stroke.error}; + background: ${theme.click.field.color.background.active}; + color: ${theme.click.field.color.text.error}; + &:hover { + border: 1px solid ${theme.click.field.color.stroke.error}; + color: ${theme.click.field.color.text.error}; + } + ` + : ` + &:focus, + &[data-state="open"] { + font: ${theme.click.field.typography.fieldText.active}; + border: 1px solid ${theme.click.field.color.stroke.active}; + background: ${theme.click.field.color.background.active}; + color: ${theme.click.field.color.text.active}; + & ~ label { + color: ${theme.click.field.color.label.active}; + font: ${theme.click.field.typography.label.active};; + } + } + ` + }; + &:disabled { + font: ${theme.click.field.typography.fieldText.disabled}; + border: 1px solid ${theme.click.field.color.stroke.disabled}; + background: ${theme.click.field.color.background.disabled}; + color: ${theme.click.field.color.text.disabled}; + cursor: not-allowed; + } + `} +`; + +export const SelectPopoverContent = styled(Content)` + width: var(--radix-popover-trigger-width); + max-height: var(--radix-popover-content-available-height); + border-radius: 0.25rem; + + ${({ theme }) => ` + border: 1px solid ${theme.click.genericMenu.item.color.stroke.default}; + background: ${theme.click.genericMenu.item.color.background.default}; + box-shadow: 0px 1px 3px 0px rgba(16, 24, 40, 0.1), + 0px 1px 2px 0px rgba(16, 24, 40, 0.06); + border-radius: 0.25rem; + `} + overflow: hidden; + display: flex; + padding: 0.5rem 0rem; + align-items: flex-start; + gap: 0.625rem; +`; + +export const SearchBarContainer = styled.div<{ $showSearch: boolean }>` + width: auto; + position: relative; + display: flex; + justify-content: flex-start; + align-items: center; + ${({ theme, $showSearch }) => ` + padding: ${ + $showSearch + ? `${theme.click.genericMenu.item.space.y} ${theme.click.genericMenu.item.space.x}` + : 0 + }; + color: ${theme.click.genericMenu.autocomplete.color.searchTerm.default}; + font: ${theme.click.genericMenu.autocomplete.typography.search.term.default}; + height: ${$showSearch ? "auto" : " 0"}; + `} +`; + +export const SearchBar = styled.input<{ $showSearch: boolean }>` + background: transparent; + border: none; + width: 100%; + outline: none; + ${({ theme, $showSearch }) => ` + min-height: ${$showSearch ? "21px" : 0}; + height: ${$showSearch ? "initial" : 0}; + ${$showSearch ? "padding-right: 24px" : "padding:0"}; + + gap: ${theme.click.genericMenu.item.space.gap}; + font: ${theme.click.genericMenu.autocomplete.typography.search.term.default}; + border-bottom: ${ + $showSearch ? `2px solid ${theme.click.genericMenu.button.color.stroke.default}` : 0 + }; + color: ${theme.click.genericMenu.autocomplete.color.searchTerm.default}; + &::placeholder { + color: ${theme.click.genericMenu.autocomplete.color.placeholder.default}; + font: ${theme.click.genericMenu.autocomplete.typography.search.placeholder.default}; + } + `} +`; + +export const SearchClose = styled.button<{ $showClose: boolean }>` + position: absolute; + ${({ theme }) => ` + top: ${theme.click.genericMenu.item.space.y}; + right: ${theme.click.genericMenu.item.space.x}; + `} + visibility: ${({ $showClose }) => ($showClose ? "visible" : "hidden")}; +`; + +export const SelectList = styled.div` + display: flex; + flex-direction: column; + width: inherit; + max-height: var(--radix-popover-content-available-height); +`; +export const SelectListContent = styled.div` + width: inherit; + overflow: overlay; + flex: 1; +`; + +export const HiddenSelectElement = styled.select` + visibility: hidden; + position: absolute; + z-index: -1; + height: 0; +`; + +export const SelectGroupContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + width: -webkit-fill-available; + width: fill-available; + width: stretch; + overflow: hidden; + background: transparent; + &[aria-selected] { + outline: none; + } + + ${({ theme }) => ` + font: ${theme.click.genericMenu.item.typography.sectionHeader.default}; + color: ${theme.click.genericMenu.item.color.text.muted}; + `}; + &[hidden] { + display: none; + } +`; + +export const SelectGroupName = styled.div` + display: flex; + width: 100%; + flex-direction: column; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + ${({ theme }) => ` + font: ${theme.click.genericMenu.item.typography.sectionHeader.default}; + color: ${theme.click.genericMenu.item.color.text.muted}; + padding: ${theme.click.genericMenu.sectionHeader.space.top} ${theme.click.genericMenu.item.space.x} ${theme.click.genericMenu.sectionHeader.space.bottom}; + gap: ${theme.click.genericMenu.item.space.gap}; + border-bottom: 1px solid ${theme.click.genericMenu.item.color.stroke.default}; + `} +`; + +export const SelectGroupContent = styled.div` + width: inherit; +`; + +export const SelectNoDataContainer = styled.div<{ $clickable: boolean }>` + border: none; + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + + &[hidden="true"] { + display: none; + } + ${({ theme, $clickable }) => ` + font: ${theme.click.genericMenu.button.typography.label.default} + padding: ${theme.click.genericMenu.button.space.y} ${ + theme.click.genericMenu.item.space.x + }; + background: ${theme.click.genericMenu.button.color.background.default}; + color: ${theme.click.genericMenu.button.color.label.default}; + &:hover { + font: ${theme.click.genericMenu.button.typography.label.hover}; + } + cursor: ${$clickable ? "pointer" : "default"} + `} +`; diff --git a/src/components/Select/common/types.ts b/src/components/Select/common/types.ts new file mode 100644 index 00000000..65ddf6d9 --- /dev/null +++ b/src/components/Select/common/types.ts @@ -0,0 +1,91 @@ +import { HTMLAttributes, ReactNode } from "react"; +import { HorizontalDirection, IconName } from "@/components"; +import { PopoverProps } from "@radix-ui/react-popover"; + +declare type DivProps = HTMLAttributes; + +interface SelectItemComponentProps + extends Omit { + separator?: boolean; + disabled?: boolean; + onSelect?: (value: string) => void; + value: string; + icon?: IconName; + iconDir?: HorizontalDirection; +} + +type SelectItemChildren = { + children: ReactNode; + label?: never; +}; + +type SelectItemLabel = { + children?: never; + label: ReactNode; +}; + +export type SelectItemProps = SelectItemComponentProps & + (SelectItemChildren | SelectItemLabel); +export interface SelectGroupProps + extends Omit, "heading"> { + heading: ReactNode; + value?: never; + onSelect?: never; +} +export interface SelectOptionItem extends Omit { + heading?: never; + label: ReactNode; + [key: `data-${string}`]: string; +} + +interface SelectGroupOptionItem extends Omit { + options: Array; + label?: never; + [key: `data-${string}`]: string; +} + +export type SelectOptionListItem = SelectGroupOptionItem | SelectOptionItem; + +type SelectOptionType = { + options: Array; + children?: never; +}; + +type SelectChildrenType = { + children: ReactNode; + options?: never; +}; + +interface InternalSelectProps + extends PopoverProps, + Omit, "onChange" | "dir" | "onSelect" | "children"> { + label?: ReactNode; + error?: ReactNode; + disabled?: boolean; + name?: string; + form?: string; + dir?: "start" | "end"; + orientation?: "horizontal" | "vertical"; + onCreateOption?: (search: string) => void; + showCheck?: boolean; + onChange: (selectedValues: Array) => void; + open: boolean; + onOpenChange: (open: boolean) => void; + value: Array; + sortable?: boolean; + onSelect: (value: string) => void; + multiple?: boolean; + showSearch?: boolean; + customText?: string; +} + +export type SelectOptionProp = SelectOptionType | SelectChildrenType; + +export type SelectContainerProps = SelectOptionProp & InternalSelectProps; + +export type SelectItemObject = { + disabled?: boolean; + value: string; + title: string; + heading?: string; +}; diff --git a/src/components/Select/common/useOption.tsx b/src/components/Select/common/useOption.tsx new file mode 100644 index 00000000..ba4f8a87 --- /dev/null +++ b/src/components/Select/common/useOption.tsx @@ -0,0 +1,15 @@ +import { useContext } from "react"; +import { OptionContext } from "./OptionContext"; + +export const useOption = () => { + const result = useContext(OptionContext); + if (!result) { + throw new Error("Context used outside of its Provider!"); + } + return result; +}; + +export const useSearch = () => { + const { search } = useOption(); + return search; +}; diff --git a/src/components/Select/selectOptions.ts b/src/components/Select/selectOptions.ts new file mode 100644 index 00000000..14da83ae --- /dev/null +++ b/src/components/Select/selectOptions.ts @@ -0,0 +1,31 @@ +import { SelectOptionListItem } from "./common/types"; + +export const selectOptions: Array = [ + { + heading: "Group label", + options: [ + { + icon: "user", + value: "content0", + label: "Content0", + }, + ], + }, + { + value: "content1", + label: "Content1 long text content", + }, + { + value: "content2", + label: "Content2", + disabled: true, + }, + { + value: "content3", + label: "Content3", + }, + { + value: "content4", + label: "Content4", + }, +]; diff --git a/src/components/Select/useSelect.tsx b/src/components/Select/useSelect.tsx deleted file mode 100644 index 0eafbb4b..00000000 --- a/src/components/Select/useSelect.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useContext } from "react"; -import { SelectContext } from "./SelectContext"; - -export const useSelect = () => { - const result = useContext(SelectContext); - if (!result) { - throw new Error("Context used outside of its Provider!"); - } - return result; -}; diff --git a/src/components/commonElement.tsx b/src/components/commonElement.tsx index 22a08dfa..b6e9ae72 100644 --- a/src/components/commonElement.tsx +++ b/src/components/commonElement.tsx @@ -8,7 +8,7 @@ export const FormRoot = styled.div<{ display: flex; width: 100%; gap: ${({ theme }) => theme.click.field.space.gap}; - ${({ $orientation = "vertical", $dir = "start" }) => ` + ${({ theme, $orientation = "vertical", $dir = "start" }) => ` flex-direction: ${ $orientation === "horizontal" ? $dir === "start" @@ -18,7 +18,13 @@ export const FormRoot = styled.div<{ ? "column-reverse" : "column" }; - align-items: ${$orientation === "vertical" ? "flex-start" : "center"}; + align-items: flex-start; + label { + padding-top: ${ + $orientation === "horizontal" ? `calc(${theme.click.field.space.y} + 1px)` : 0 + }; + ${$orientation === "horizontal" ? "line-height: 1lh;" : ""} + } `} * { box-shadow: none; @@ -117,3 +123,15 @@ export const FormElementContainer = styled.div` width: stretch; gap: ${({ theme }) => theme.click.field.space.gap}; `; + +export const EllipsisContainer = styled.span` + display: flex; + white-space: nowrap; + overflow: hidden; + justify-content: flex-end; + gap: inherit; + & > *:not(button) { + overflow: hidden; + text-overflow: ellipsis; + } +`; diff --git a/src/components/index.ts b/src/components/index.ts index bd872fab..fffaa458 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -15,17 +15,20 @@ export { CardSecondary } from "./CardSecondary/CardSecondary"; export { CardPrimary } from "./CardPrimary/CardPrimary"; export { Checkbox } from "./Checkbox/Checkbox"; export { CodeBlock } from "./CodeBlock/CodeBlock"; +export { EllipsisContent } from "./EllipsisContent/EllipsisContent"; export { InlineCodeBlock } from "./CodeBlock/InlineCodeBlock"; export { ContextMenu } from "./ContextMenu/ContextMenu"; export { default as Flags } from "./icons/Flags"; export { HoverCard } from "./HoverCard/HoverCard"; export { Link } from "./Link/Link"; +export { Logo } from "./Logos/Logo"; export { NumberField } from "./Input/NumberField"; export { PasswordField } from "./Input/PasswordField"; export { Popover } from "./Popover/Popover"; export { RadioGroup } from "./RadioGroup/RadioGroup"; export { SearchField } from "./Input/SearchField"; -export { Select } from "./Select/Select"; +export { Select } from "./Select/SingleSelect"; +export { MultiSelect } from "./Select/MultiSelect"; export { default as Separator } from "./Separator/Separator"; export { SidebarNavigationItem } from "./SidebarNavigationItem/SidebarNavigationItem"; export { SidebarCollapsibleItem } from "./SidebarCollapsibleItem/SidebarCollapsibleItem"; diff --git a/src/components/types.ts b/src/components/types.ts index 2725b783..5a27acd6 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -4,7 +4,7 @@ import { TextProps } from "./Typography/Text/Text"; import { TabsProps } from "./Tabs/Tabs"; import { SpacerProps } from "./Spacer/Spacer"; import { SidebarNavigationItemProps } from "./SidebarNavigationItem/SidebarNavigationItem"; -import { SelectProps } from "./Select/Select"; +import { SelectProps } from "./Select/SingleSelect"; import { SearchFieldProps } from "./Input/SearchField"; import { RadioGroupProps, RadioGroupItemProps } from "./RadioGroup/RadioGroup"; import { PopoverProps } from "@radix-ui/react-popover"; @@ -28,6 +28,8 @@ import { SidebarCollapsibleItemProps } from "./SidebarCollapsibleItem/SidebarCol import { SidebarCollapsibleTitleProps } from "./SidebarCollapsibleTitle/SidebarCollapsibleTitle"; export type { Menu, SplitButtonProps } from "./SplitButton/SplitButton"; export type { ToastProps } from "./Toast/Toast"; +export type { SelectOptionListItem } from "./Select/common/types"; +export type { MultiSelectProps } from "./Select/MultiSelect"; export type States = "default" | "active" | "disabled" | "error" | "hover"; export type HorizontalDirection = "start" | "end";