diff --git a/.eslintrc.js b/.eslintrc.js index 4f8c8981..9d4e1401 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,6 +29,9 @@ module.exports = { // Enable prettier "prettier/prettier": "error", + // No consoles! + "no-console": "error", + // We're not creating PropTypes anywhere so don't both checking for them "react/prop-types": "off", @@ -40,7 +43,7 @@ module.exports = { * React hooks */ "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", + "react-hooks/exhaustive-deps": "error", // In favor of @typescript-eslint variants "no-use-before-define": "off", diff --git a/.gitignore b/.gitignore index 14dc925c..199138eb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,10 @@ tsconfig.tsbuildinfo /ConfirmationTooltip /emotionCacheProviderFactory /fonts +/Form* /icons /illustrations +/Input /List* /Loaders /Menu* diff --git a/package-lock.json b/package-lock.json index bb22d5c1..0acce851 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4256,21 +4256,13 @@ } }, "@babel/runtime-corejs3": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.12.1.tgz", - "integrity": "sha512-umhPIcMrlBZ2aTWlWjUseW9LjQKxi1dpFlQS8DzsxB//5K+u6GLTC/JliPKHsd5kJVPIU6X/Hy0YvWOYPcMxBw==", + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.12.5.tgz", + "integrity": "sha512-roGr54CsTmNPPzZoCP1AmDXuBoNao7tnSA83TXTwt+UK5QVyh1DIJnrgYRPWKCF2flqZQXwa7Yr8v7VmLzF0YQ==", "dev": true, "requires": { "core-js-pure": "^3.0.0", "regenerator-runtime": "^0.13.4" - }, - "dependencies": { - "regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", - "dev": true - } } }, "@babel/template": { @@ -8969,19 +8961,19 @@ } }, "@testing-library/dom": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.26.0.tgz", - "integrity": "sha512-fyKFrBbS1IigaE3FV21LyeC7kSGF84lqTlSYdKmGaHuK2eYQ/bXVPM5vAa2wx/AU1iPD6oQHsxy2QQ17q9AMCg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.29.0.tgz", + "integrity": "sha512-0hhuJSmw/zLc6ewR9cVm84TehuTd7tbqBX9pRNSp8znJ9gTmSgesdbiGZtt8R6dL+2rgaPFp9Yjr7IU1HWm49w==", "dev": true, "requires": { "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.10.3", + "@babel/runtime": "^7.12.5", "@types/aria-query": "^4.2.0", "aria-query": "^4.2.2", "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.1", + "dom-accessibility-api": "^0.5.4", "lz-string": "^1.4.4", - "pretty-format": "^26.4.2" + "pretty-format": "^26.6.2" }, "dependencies": { "@babel/code-frame": { @@ -9023,19 +9015,10 @@ } } }, - "@babel/runtime": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.1.tgz", - "integrity": "sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, "@jest/types": { - "version": "26.5.2", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.5.2.tgz", - "integrity": "sha512-QDs5d0gYiyetI8q+2xWdkixVQMklReZr4ltw7GFDtb4fuJIBCE6mzj2LnitGqCuAlLap6wPyb8fpoHgwZz5fdg==", + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", "dev": true, "requires": { "@types/istanbul-lib-coverage": "^2.0.0", @@ -9121,15 +9104,15 @@ "dev": true }, "pretty-format": { - "version": "26.5.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.5.2.tgz", - "integrity": "sha512-VizyV669eqESlkOikKJI8Ryxl/kPpbdLwNdPs2GrbQs18MpySB5S0Yo0N7zkg2xTRiFq4CFw8ct5Vg4a0xP0og==", + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", "dev": true, "requires": { - "@jest/types": "^26.5.2", + "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", - "react-is": "^16.12.0" + "react-is": "^17.0.1" }, "dependencies": { "ansi-styles": { @@ -9153,15 +9136,9 @@ } }, "react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true - }, - "regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", "dev": true }, "supports-color": { @@ -9176,9 +9153,9 @@ } }, "@testing-library/jest-dom": { - "version": "5.11.4", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.4.tgz", - "integrity": "sha512-6RRn3epuweBODDIv3dAlWjOEHQLpGJHB2i912VS3JQtsD22+ENInhdDNl4ZZQiViLlIfFinkSET/J736ytV9sw==", + "version": "5.11.6", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.6.tgz", + "integrity": "sha512-cVZyUNRWwUKI0++yepYpYX7uhrP398I+tGz4zOlLVlUYnZS+Svuxv4fwLeCIy7TnBYKXUaOlQr3vopxL8ZfEnA==", "dev": true, "requires": { "@babel/runtime": "^7.9.2", @@ -9191,15 +9168,6 @@ "redent": "^3.0.0" }, "dependencies": { - "@babel/runtime": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.1.tgz", - "integrity": "sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -9245,30 +9213,12 @@ "source-map-resolve": "^0.6.0" } }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", - "dev": true - }, - "regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", - "dev": true - }, "source-map-resolve": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", @@ -9278,69 +9228,36 @@ "atob": "^2.1.2", "decode-uri-component": "^0.2.0" } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } } } }, "@testing-library/react": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.1.0.tgz", - "integrity": "sha512-Nfz58jGzW0tgg3irmTB7sa02JLkLnCk+QN3XG6WiaGQYb0Qc4Ok00aujgjdxlIQWZHbb4Zj5ZOIeE9yKFSs4sA==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.2.tgz", + "integrity": "sha512-jaxm0hwUjv+hzC+UFEywic7buDC9JQ1q3cDsrWVSDAPmLotfA6E6kUHlYm/zOeGCac6g48DR36tFHxl7Zb+N5A==", "dev": true, "requires": { - "@babel/runtime": "^7.11.2", - "@testing-library/dom": "^7.26.0" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.1.tgz", - "integrity": "sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", - "dev": true - } + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^7.28.1" + } + }, + "@testing-library/react-hooks": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.7.0.tgz", + "integrity": "sha512-TwfbY6BWtWIHitjT05sbllyLIProcysC0dF0q1bbDa7OHLC6A6rJOYJwZ13hzfz3O4RtOuInmprBozJRyyo7/g==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/testing-library__react-hooks": "^3.4.0" } }, "@testing-library/user-event": { - "version": "12.1.8", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.1.8.tgz", - "integrity": "sha512-UUcTuT8HyDwGaRXgNgvMS1uOlreLv9+WsXi1FNj3mmumnB2d3hv2cov0RAd99lx8QI2CtAAOELxoihGj8x/nWg==", + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.5.0.tgz", + "integrity": "sha512-9uXr4+OwjHVUxzdfYZ2yCnF3xlEzr8cZOdqjGnqD8Qb1NoCJrm7UXxG3RUpL2QqcqZ1eqVuxkFJTCky5Yit+XQ==", "dev": true, "requires": { "@babel/runtime": "^7.10.2" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.1.tgz", - "integrity": "sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", - "dev": true - } } }, "@tippyjs/react": { @@ -9839,6 +9756,15 @@ "@types/react": "*" } }, + "@types/react-test-renderer": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.0.tgz", + "integrity": "sha512-nvw+F81OmyzpyIE1S0xWpLonLUZCMewslPuA8BtjSKc5XEbn8zEQBXS7KuOLHTNnSOEM2Pum50gHOoZ62tqTRg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.3.tgz", @@ -9892,14 +9818,23 @@ "dev": true }, "@types/testing-library__jest-dom": { - "version": "5.9.4", - "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.4.tgz", - "integrity": "sha512-6spmpkKOCVCO9XolAR23gfv09Nfd4QByRM3WbnYnPhVfjmOzEKlNrcj6GqFLZKduUvtJIH7Mf5t2TY6rs93zDA==", + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz", + "integrity": "sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==", "dev": true, "requires": { "@types/jest": "*" } }, + "@types/testing-library__react-hooks": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz", + "integrity": "sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q==", + "dev": true, + "requires": { + "@types/react-test-renderer": "*" + } + }, "@types/tinycolor2": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.2.tgz", @@ -10842,23 +10777,6 @@ "requires": { "@babel/runtime": "^7.10.2", "@babel/runtime-corejs3": "^7.10.2" - }, - "dependencies": { - "@babel/runtime": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.1.tgz", - "integrity": "sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, - "regenerator-runtime": { - "version": "0.13.7", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", - "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", - "dev": true - } } }, "arr-diff": { @@ -29700,6 +29618,24 @@ "integrity": "sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==", "dev": true }, + "react-shallow-renderer": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.14.1.tgz", + "integrity": "sha512-rkIMcQi01/+kxiTE9D3fdS959U1g7gs+/rborw++42m1O9FAQiNI/UNRZExVUoAOprn4umcXf+pFRou8i4zuBg==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0" + }, + "dependencies": { + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, "react-sizeme": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/react-sizeme/-/react-sizeme-2.6.12.tgz", @@ -29725,6 +29661,26 @@ "refractor": "^3.1.0" } }, + "react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-/dRae3mj6aObwkjCcxZPlxDFh73XZLgvwhhyON2haZGUEhiaY5EjfAdw+d/rQmlcFwdTpMXCSGVk374QbCTlrA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "react-is": "^17.0.1", + "react-shallow-renderer": "^16.13.1", + "scheduler": "^0.20.1" + }, + "dependencies": { + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, "react-textarea-autosize": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.3.0.tgz", @@ -31170,6 +31126,16 @@ "xmlchars": "^2.1.1" } }, + "scheduler": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.1.tgz", + "integrity": "sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "schema-utils": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", diff --git a/package.json b/package.json index 561e8e1a..615ee5a1 100644 --- a/package.json +++ b/package.json @@ -107,9 +107,10 @@ "@svgr/plugin-jsx": "^4.3.0", "@svgr/plugin-prettier": "^4.2.0", "@svgr/plugin-svgo": "^4.2.0", - "@testing-library/jest-dom": "^5.11.4", - "@testing-library/react": "^11.1.0", - "@testing-library/user-event": "^12.1.8", + "@testing-library/jest-dom": "^5.11.6", + "@testing-library/react": "^11.2.2", + "@testing-library/react-hooks": "^3.7.0", + "@testing-library/user-event": "^12.5.0", "@types/babel__traverse": "^7.0.7", "@types/faker": "^4.1.8", "@types/jest": "^26.0.15", @@ -148,6 +149,7 @@ "prettier": "^2.1.1", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-test-renderer": "^17.0.1", "rimraf": "^2.6.3", "rollup": "^1.27.8", "rollup-plugin-multi-input": "^1.0.3", diff --git a/rollup.config.js b/rollup.config.js index 0fbb1d2b..3b5a9440 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -66,6 +66,7 @@ function CJS() { "downshift", "framer-motion", "lodash/omit", + "lodash/uniqueId", "prop-types", "react-dom", "react", diff --git a/src/Form/index.ts b/src/Form/index.ts new file mode 100644 index 00000000..52a95ac0 --- /dev/null +++ b/src/Form/index.ts @@ -0,0 +1,7 @@ +export { FormControl as Control } from "../FormControl"; +export { FormDescription as Description } from "../FormDescription"; +export { FormEndAdornment as EndAdornment } from "../FormEndAdornment"; +export { FormErrorMessage as ErrorMessage } from "../FormErrorMessage"; +export { FormHelperText as HelperText } from "../FormHelperText"; +export { FormLabel as Label } from "../FormLabel"; +export { FormStartAdornment as StartAdornment } from "../FormStartAdornment"; diff --git a/src/FormControl/FormControl.spec.tsx b/src/FormControl/FormControl.spec.tsx new file mode 100644 index 00000000..e9e6e65f --- /dev/null +++ b/src/FormControl/FormControl.spec.tsx @@ -0,0 +1,117 @@ +/*eslint react/jsx-sort-props: "error" */ +import "@testing-library/jest-dom"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@testing-library/react"; +import { FormControl } from "../FormControl"; +import { FormLabel } from "../FormLabel"; +import { FormHelperText } from "../FormHelperText"; +import { Input } from "../Input"; +import { FormErrorMessage } from "../FormErrorMessage"; + +test("when passed a label, renders it", () => { + render( + + label text + + , + ); + + expect(screen.getByText("label text")).toBeInTheDocument(); +}); + +test("when the label is clicked it focuses the input", () => { + const labelText = "label text"; + const { container } = render( + + {labelText} + + , + ); + + // Use `throw` so TypeScript knows `label` is not null + const label = container.querySelector("label"); + if (!label) throw new Error("Could not find label"); + + const input = screen.getByLabelText(labelText); + expect(input).not.toHaveFocus(); + + userEvent.click(label); + expect(input).toHaveFocus(); +}); + +test("when passed `HelperText`, helper text is rendered", () => { + const { container } = render( + + helper text + + , + ); + + expect(screen.queryByText("helper text")).toBeInTheDocument(); + expect(container.querySelectorAll("svg")).toHaveLength(1); +}); + +test("when passed two `HelperText` and `FormErrorMessage`, only renders the `FormErrorMessage`", () => { + const { container } = render( + + + helper text + error text + , + ); + + expect(screen.getByText("error text")).toBeInTheDocument(); + expect(screen.queryByText("helper text")).not.toBeInTheDocument(); + expect(container.querySelectorAll("svg")).toHaveLength(1); +}); + +test("when passed ``, renders error and svg", () => { + const { container } = render( + + + error text + , + ); + + expect(screen.getByText("error text")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); +}); + +test("when passed `` witout `showIcon` prop, renders no svg", () => { + const { container } = render( + + + helper text + , + ); + + expect(screen.getByText("helper text")).toBeInTheDocument(); + expect(container.querySelector("svg")).not.toBeInTheDocument(); +}); + +test.only("when passed `` with `showIcon` prop, renders svg", () => { + const { container } = render( + + helper text + + , + ); + + expect(screen.getByText("helper text")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); +}); + +test("when not passed `autoFocus` prop, should not have focus after mounting", () => { + const labelText = "label text"; + render( + + {labelText} + + , + ); + + const formField = screen.getByLabelText(labelText); + + expect(formField).not.toHaveFocus(); +}); diff --git a/src/FormControl/FormControls.stories.mdx b/src/FormControl/FormControls.stories.mdx new file mode 100644 index 00000000..ede36ede --- /dev/null +++ b/src/FormControl/FormControls.stories.mdx @@ -0,0 +1,352 @@ +import { FormControl } from "../FormControl"; +import { FormDescription } from "../FormDescription"; +import { FormEndAdornment } from "../FormEndAdornment"; +import { FormErrorMessage } from "../FormErrorMessage"; +import { FormLabel } from "../FormLabel"; +import { FormStartAdornment } from "../FormStartAdornment"; +import { FormHelperText } from "../FormHelperText"; +import { IconShip2 } from "../icons/IconShip2"; +import { Input } from "../Input"; +import { Meta, Story, ArgsTable, Canvas } from "@storybook/addon-docs/blocks"; +import { Select } from "../Select"; + +export const SampleIcon = () => ( + +); + + + +# FormControl + +FormControl is a layout component intended to abstract away an form field, its label, its helper text, and its error state. + +## Shortcut + +All form components are exported from `Form` with the preceeding `Form` removed: + +- `Form.Control` +- `Form.Description` +- `Form.EndAdornment` +- `Form.ErrorMessage` +- `Form.Label` +- `Form.StartAdornment` +- `Form.HelperText` + +## Layout + +`FormElement` can lay out it's content vertically or horizontally + +### `layout="vertical"` + + + + + Small input + + Small input is small in terms of height and type size. Padding is less + both top and bottom as well. + + + + + + +### `layout="horizontal"` + + + + + Standard input + + Most common input. Start here when trying to create a form. + + + + + + +### Layout customization + +You can further customize how the content is styled by using the props `containerSectionAs`, `labelSectionAs`, and `contentSectionAs`. + +For example; you can use these props to alter the sizing of the elements + + + + } + layout="horizontal" + size="standard" + > + Standard input + + Most common input. Start here when trying to create a form. + + + + + + +## Anatomy + +Anatomy of a complete form field. A label is required (or at the very least, it should be very clear why a label might not be needed). Description, placeholder text, and helper text are all optional. + +### Vertical + + + + + Input Label + + Keep it simple, your organization ID is a unique identifier. Don’t + worry, you can always change it later. + + + + Your organization ID can’t contain any special characters or spaces. + + + + + + Input Label with info icon + + Keep it simple, your organization ID is a unique identifier. Don’t + worry, you can always change it later. + + + + Your organization ID can’t contain any special characters or spaces. + + + + + +### Horizontal + + + + + Input Label + + Keep it simple, your organization ID is a unique identifier. Don’t + worry, you can always change it later. + + + + Your organization ID can’t contain any special characters or spaces. + + + + + + Input Label with info icon + + Keep it simple, your organization ID is a unique identifier. Don’t + worry, you can always change it later. + + + + Your organization ID can’t contain any special characters or spaces. + + + + + +## Error States + +You can use the `FormErrorMessage` component to display an error state. + +If you render a `FormErrorMessage` then any `FormHelperText` will not be rendered. + + + + + Input Label + + This component has helper text that will be replaced by the error + message. + + + + Errors are a variant of helper text with a specific type of information + to communicate, for example, errors. + + + Your ID can’t contain special characters. + + + + + +## Adornments + +You can use `FormStartAdornment` and/or `FormEndAdornment` components to add content to the beginning or end of the element + + + + + + + + + + + + + + + +## Examples of Space Kit Form Elements + +### `Input` + + + + + Input Label + + This component has helper text that will be replaced by the error + message. + + } + name="input" + defaultValue="Justin Anastos" + style={{ width: "100%" }} + /> + + Your organization ID can’t contain any special characters or spaces. + + + + + + Large size + + This component has helper text that will be replaced by the error + message. + + + + Your organization ID can’t contain any special characters or spaces. + + + + + + Error state + + This component has helper text that will be replaced by the error + message. + + + + Your organization ID can’t contain any special characters or spaces. + + + Your ID can’t contain special characters. + + + + + +### `Select` + + + + + Input Label + + This component has helper text that will be replaced by the error + message. + + + + Your organization ID can’t contain any special characters or spaces. + + + + + + Large size + + This component has helper text that will be replaced by the error + message. + + + + Your organization ID can’t contain any special characters or spaces. + + + + + + Error state + + This component has helper text that will be replaced by the error + message. + + + + Errors are a variant of helper text with a specific type of information + to communicate, for example, errors. + + + Your ID can’t contain special characters. + + + + + +## Props + + diff --git a/src/FormControl/index.tsx b/src/FormControl/index.tsx new file mode 100644 index 00000000..0af8cf59 --- /dev/null +++ b/src/FormControl/index.tsx @@ -0,0 +1,218 @@ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { ClassNames, jsx } from "@emotion/core"; +import React from "react"; +import { As, createElementFromAs } from "../shared/createElementFromAs"; +import { + FormControlContextProvider, + useFormControlInternalContext, +} from "../shared/FormControlContext"; +import uniqueId from "lodash/uniqueId"; + +export { useFormControlContext } from "../shared/FormControlContext"; + +interface Props + extends Pick< + React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >, + "className" | "style" + > { + /** + * Override how the outermost container is rendered. + * + * @default "div" + */ + containerAs?: As; + + /** + * Override how the content section is rendered. This contains the input and + * helper/error text. + * + * This is useful if you want to change the default size of the content versus + * label sections. + * + * @default "section" + */ + contentSectionAs?: As; + + /** + * This ID will be used to tie all components together with accessibility + */ + id?: string; + + /** + * Override how the label section is rendered. This contains the label and + * description. + * + * This is useful if you want to change the default size of the content versus + * label sections. + * + * @default "section" + */ + labelSectionAs?: As; + + /** + * How to lay out the form element + */ + layout?: "vertical" | "horizontal"; +} + +const FormControl: React.FC = ({ + children, + className, + containerAs = "div", + contentSectionAs = "section", + id, + labelSectionAs = "section", + layout = "vertical", + ...props +}) => { + const { + description, + descriptionId, + endAdornment, + errorMessageElement, + helper, + label, + labelId, + startAdornment, + } = useFormControlInternalContext(); + + /** + * Take a map keyed for each possible valye of `layout` and return the value + * corresponding to the current `layout` + */ + function layoutValue(map: Record): T { + return map[layout]; + } + + return ( + + {({ css, cx }) => { + return React.cloneElement( + createElementFromAs(containerAs), + { + ...props, + className: cx( + css({ + alignItems: layoutValue({ + vertical: "initial", + horizontal: "center", + } as const), + display: "flex", + flexDirection: layoutValue({ + vertical: "column", + horizontal: "row", + } as const), + }), + className, + React.isValidElement(containerAs) && containerAs.props.className, + ), + style: { + ...props.style, + ...(React.isValidElement(containerAs) && containerAs.props.style), + }, + }, + <> + {(label || description) && + React.cloneElement( + createElementFromAs(labelSectionAs), + { + className: cx( + css({ + flex: layoutValue({ + vertical: undefined, + horizontal: 1, + } as const), + marginBottom: layoutValue({ + vertical: 8, + horizontal: 0, + } as const), + marginRight: layoutValue({ + vertical: undefined, + horizontal: 8, + } as const), + }), + React.isValidElement(labelSectionAs) && + labelSectionAs.props.className, + ), + }, + <> + {React.isValidElement(label) && + React.cloneElement(label, { + ...label.props, + id: labelId, + htmlFor: id, + })} + {React.isValidElement(description) && + React.cloneElement(description, { + ...description.props, + id: descriptionId, + })} + , + )} + + {React.cloneElement( + createElementFromAs(contentSectionAs), + { + className: cx( + css({ + flex: layoutValue({ + vertical: undefined, + horizontal: 1, + } as const), + }), + React.isValidElement(contentSectionAs) && + contentSectionAs.props.className, + ), + }, + <> +
+ {startAdornment} + {children} + {endAdornment} +
+ {(helper || errorMessageElement) && ( +
+ {errorMessageElement || helper} +
+ )} + , + )} + , + ); + }} +
+ ); +}; + +const FormControlWrapper: React.FC> = ( + props, +) => { + /** + * Backup ID to be used if none are passed in props. + * + * Use `useMemo` so this is consistent for the lifecycle of this element. + */ + const fallbackId = React.useMemo( + () => uniqueId("space-kit-form-control-"), + [], + ); + const id = props.id ?? fallbackId; + + return ( + + + + ); +}; + +export { FormControlWrapper as FormControl }; diff --git a/src/FormDescription/index.tsx b/src/FormDescription/index.tsx new file mode 100644 index 00000000..8fcb3d1c --- /dev/null +++ b/src/FormDescription/index.tsx @@ -0,0 +1,58 @@ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import * as React from "react"; +import * as typography from "../typography"; +import { css, jsx } from "@emotion/core"; +import { colors } from "../colors"; +import { useFormControlInternalContext } from "../shared/FormControlContext"; + +interface Props + extends Pick< + React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >, + "className" | "style" | "id" + > { + children: React.ReactNode; +} + +/** + * Component to render a form description. + * + * If this component is rendered in the children of `FormControl`, then + * `FormControl` will render this element in it's layout. Otherwise, it's + * rendered as-is. + */ +export const FormDescription: React.FC = ({ + children, + className, + id, + style, +}) => { + const { descriptionId, setDescription } = useFormControlInternalContext(); + + const element = React.useMemo( + () => ( +
+ {children} +
+ ), + [children, className, descriptionId, id, style], + ); + + React.useLayoutEffect(() => { + setDescription?.(element); + }, [element, setDescription]); + + return setDescription ? null : element; +}; diff --git a/src/FormEndAdornment/index.tsx b/src/FormEndAdornment/index.tsx new file mode 100644 index 00000000..5d4496cd --- /dev/null +++ b/src/FormEndAdornment/index.tsx @@ -0,0 +1,46 @@ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import * as React from "react"; +import { css, jsx } from "@emotion/core"; +import { useFormControlInternalContext } from "../shared/FormControlContext"; + +/** + * Component to render a end adornment. + * + * This is intended to be rendered below ``. If this is rendered on + * it's own; it will render `children` without any modification. + */ +export function FormEndAdornment({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}): React.ReactNode { + const { setEndAdornment } = useFormControlInternalContext(); + + const element = React.useMemo( + () => ( +
+ {children} +
+ ), + [children, className], + ); + + React.useLayoutEffect(() => { + setEndAdornment?.(element); + }, [element, setEndAdornment]); + + return setEndAdornment ? null : element; +} diff --git a/src/FormErrorMessage/index.tsx b/src/FormErrorMessage/index.tsx new file mode 100644 index 00000000..508fde2b --- /dev/null +++ b/src/FormErrorMessage/index.tsx @@ -0,0 +1,63 @@ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import * as React from "react"; +import * as typography from "../typography"; +import { colors } from "../colors"; +import { css, jsx } from "@emotion/core"; +import { useFormControlInternalContext } from "../shared/FormControlContext"; +import { IconWarningSolid } from "../icons/IconWarningSolid"; + +interface Props { + children: React.ReactNode; + className?: string; +} + +/** + * Component to render a form's error message + * + * This is intended to be rendered below ``. If this is rendered on + * it's own; it will render `children` without any modification. + */ +export const FormErrorMessage: React.FC = ({ children, className }) => { + const { + feedbackId, + setErrorMessageElement, + } = useFormControlInternalContext(); + + const element = React.useMemo(() => { + return ( +
+ + +
{children}
+
+ ); + }, [className, feedbackId, children]); + + React.useLayoutEffect(() => { + // This will cause a bug if you change the `error` prop + setErrorMessageElement?.(element); + }, [setErrorMessageElement, element]); + + // If `setErrorMessageElement` exists then we're rendering this under the form control + // context provider. `FormControl` will pull that element from the context and + // insert into the layout, so return `null`. + return setErrorMessageElement ? null : element; +}; diff --git a/src/FormHelperText/index.tsx b/src/FormHelperText/index.tsx new file mode 100644 index 00000000..d19f7de4 --- /dev/null +++ b/src/FormHelperText/index.tsx @@ -0,0 +1,68 @@ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import * as React from "react"; +import * as typography from "../typography"; +import { colors } from "../colors"; +import { css, jsx } from "@emotion/core"; +import { IconInfo } from "../icons/IconInfo"; +import { useFormControlInternalContext } from "../shared/FormControlContext"; + +interface Props { + children: React.ReactNode; + className?: string; + /** + * Indicates to show the blue (i) icon + */ + showIcon?: boolean; +} + +/** + * Component to render a helper text + * + * This is intended to be rendered below ``. If this is rendered on + * it's own; it will render `children` without any modification. + */ +export const FormHelperText: React.FC = ({ + children, + className, + showIcon = false, +}) => { + const { setHelper } = useFormControlInternalContext(); + + const element = React.useMemo(() => { + return ( +
+ {showIcon ? ( + + ) : null} + +
{children}
+
+ ); + }, [className, showIcon, children]); + + React.useLayoutEffect(() => { + // This will cause a bug if you change the `error` prop + setHelper?.(element); + }, [element, setHelper]); + + // If `setHelper` exists then we're rendering this under the form control + // context provider. `FormControl` will pull that element from the context and + // insert into the layout, so return `null`. + return setHelper ? null : element; +}; diff --git a/src/FormLabel/index.tsx b/src/FormLabel/index.tsx new file mode 100644 index 00000000..a76e467b --- /dev/null +++ b/src/FormLabel/index.tsx @@ -0,0 +1,79 @@ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import * as React from "react"; +import * as typography from "../typography"; +import { css, jsx } from "@emotion/core"; +import { useFormControlInternalContext } from "../shared/FormControlContext"; + +interface Props + extends Pick< + React.DetailedHTMLProps< + React.LabelHTMLAttributes, + HTMLLabelElement + >, + "aria-invalid" | "className" | "style" | "id" | "htmlFor" + > { + children: React.ReactNode; +} + +/** + * Component to render a form label. + * + * If this component is rendered in the children of `FormControl`, then + * `FormControl` will render this element in it's layout. Otherwise, it's + * rendered as-is. + */ +export const FormLabel: React.FC = ({ + "aria-invalid": ariaInvalid, + children, + className, + htmlFor, + id, + style, +}) => { + const { + errorMessageElement, + setLabel, + labelId, + id: inputId, + description, + } = useFormControlInternalContext(); + + const element = React.useMemo( + () => ( + + ), + [ + ariaInvalid, + children, + className, + description, + errorMessageElement, + htmlFor, + id, + inputId, + labelId, + style, + ], + ); + + React.useLayoutEffect(() => { + setLabel?.(element); + }, [element, setLabel]); + + return setLabel ? null : element; +}; diff --git a/src/FormStartAdornment/index.tsx b/src/FormStartAdornment/index.tsx new file mode 100644 index 00000000..826f2294 --- /dev/null +++ b/src/FormStartAdornment/index.tsx @@ -0,0 +1,45 @@ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import * as React from "react"; +import { css, jsx } from "@emotion/core"; +import { useFormControlInternalContext } from "../shared/FormControlContext"; + +/** + * Component to render a start adornment. + * + * This is intended to be rendered below ``. If this is rendered on + * it's own; it will render `children` without any modification. + */ +export function FormStartAdornment({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}): React.ReactNode { + const { setStartAdornment } = useFormControlInternalContext(); + + const element = React.useMemo( + () => ( +
+ {children} +
+ ), + [children, className], + ); + + React.useLayoutEffect(() => { + setStartAdornment?.(element); + }, [element, setStartAdornment]); + + return setStartAdornment ? null : element; +} diff --git a/src/Input/Input.spec.tsx b/src/Input/Input.spec.tsx new file mode 100644 index 00000000..0d01ece4 --- /dev/null +++ b/src/Input/Input.spec.tsx @@ -0,0 +1,59 @@ +/*eslint react/jsx-sort-props: "error" */ +import "@testing-library/jest-dom"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@testing-library/react"; +import { Input } from "../Input"; + +test("renders placeholder", () => { + render(); + + expect(screen.getByPlaceholderText("placeholder")).toBeInTheDocument(); +}); + +test("calls events", () => { + const onBlur = jest.fn(); + const onChange = jest.fn(); + const onFocus = jest.fn(); + + render( + , + ); + + const input = screen.getByRole("textbox") as HTMLInputElement; + + expect(input).toBeInTheDocument(); + + expect(onBlur).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(onFocus).not.toHaveBeenCalled(); + + input.focus(); + expect(onBlur).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(onFocus).toHaveBeenCalledTimes(1); + + userEvent.type(input, "test"); + expect(onBlur).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledTimes(4); + + input.blur(); + expect(onBlur).toHaveBeenCalledTimes(1); +}); + +test("when disabled, does not accept input", () => { + const textInput = "this is text i typed"; + + render(); + + const textbox = screen.getByRole("textbox") as HTMLInputElement; + expect(textbox).toBeInTheDocument(); + userEvent.type(textbox, textInput); + expect(textbox.value).toBe(""); +}); diff --git a/src/Input/Input.stories.mdx b/src/Input/Input.stories.mdx new file mode 100644 index 00000000..5705dfff --- /dev/null +++ b/src/Input/Input.stories.mdx @@ -0,0 +1,63 @@ +import { Input } from "../Input"; +import { Meta, Story, ArgsTable, Canvas } from "@storybook/addon-docs/blocks"; +import { FormControl } from "../FormControl"; +import { FormErrorMessage } from "../FormErrorMessage"; + + + +# Input + +## Sizes + + + + + + + + + + + + + +## Input states + +Inputs automatically handle their input states + + + + + + + + + + + + + + + + + + + +## FormControl context + +`Select` is prepared to handle values passed from `FormControl` via `FormControlContext`. + +### Error State + + + + + + error message + + + + +## Props + + diff --git a/src/Input/index.tsx b/src/Input/index.tsx new file mode 100644 index 00000000..4243ae8c --- /dev/null +++ b/src/Input/index.tsx @@ -0,0 +1,113 @@ +/** @jsx jsx */ +/** @jsxFrag React.Fragment */ +import { css, jsx } from "@emotion/core"; +import React from "react"; +import * as typography from "../typography"; +import { colors } from "../colors"; +import { useFormControlContext } from "../FormControl"; +import { inputHeightDictionary } from "../shared/inputHeightDictionary"; + +interface Props + extends Omit< + React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement + >, + "size" | "type" + > { + /** + * Size of text and padding inside the input + * + * Defaults to `standard` + */ + size?: keyof typeof inputHeightDictionary; + + type?: + | "button" + | "checkbox" + | "color" + | "date" + | "datetime-local" + | "email" + | "file" + | "hidden" + | "image" + | "month" + | "number" + | "password" + | "radio" + | "range" + | "reset" + | "search" + | "submit" + | "tel" + | "text" + | "time" + | "url" + | "week"; +} + +/** + * Component that decorates an `input` + * + * `className` and `style` are not available on the props because it would not + * be obvious where those props would be added in the DOM. To add a `className` + * to either the containing `div` or the underlying `input`, you must use + * `containerAs` and `inputAs`. + */ +export const Input: React.FC = ({ + size = "standard", + type = "text", + ...props +}) => { + const { + describedBy, + endAdornment, + labelledBy, + hasError, + id, + startAdornment, + } = useFormControlContext(); + + return ( + + ); +}; diff --git a/src/ListHeading/index.tsx b/src/ListHeading/index.tsx index b09c5145..bbbd144d 100644 --- a/src/ListHeading/index.tsx +++ b/src/ListHeading/index.tsx @@ -22,6 +22,8 @@ export const ListHeading = React.forwardRef< HTMLHeadingElement, React.PropsWithChildren >(({ children, count, ...props }, ref) => { + const { onClick } = props; + // Stop click events so we don't try to close the list when clicking something // non-interactive const handleClick = React.useCallback>( @@ -29,9 +31,9 @@ export const ListHeading = React.forwardRef< event.preventDefault(); event.stopPropagation(); - props.onClick?.(event); + onClick?.(event); }, - [props.onClick], + [onClick], ); return ( diff --git a/src/Select/Select.story.mdx b/src/Select/Select.story.mdx index 7443cb1c..41f56937 100644 --- a/src/Select/Select.story.mdx +++ b/src/Select/Select.story.mdx @@ -1,8 +1,10 @@ -import userEvent from "@testing-library/user-event"; import { findByRole } from "@testing-library/dom"; +import { FormControl } from "../FormControl"; +import { FormErrorMessage } from "../FormErrorMessage"; import { Meta, Story, ArgsTable, Canvas } from "@storybook/addon-docs/blocks"; import { PerformUserInteraction } from "../shared/PerformUserInteraction"; import { Select as SpaceKitSelect } from "../Select"; +import userEvent from "@testing-library/user-event"; @@ -48,15 +50,23 @@ You must use the same `` elements you would use for a n - ( + <>{selectedItem?.children ?? "select an item"} + )} + > - ( + <>{selectedItem?.children ?? "select an item"} + )} + > @@ -68,8 +78,12 @@ You must use the same `` elements you would use for a n - ( + <>{selectedItem?.children ?? "select an item"} + )} + > @@ -101,8 +115,13 @@ If you inspect the `label` shown in the following story; you'll see that `id` an - label} defaultValue=""> - + label} + defaultValue="" + renderTriggerNode={(selectedItem) => ( + <>{selectedItem?.children ?? "select an item"} + )} + > @@ -125,8 +144,13 @@ Selects can be displayed using any of the `feel` values from `Button`. - ( + <>{selectedItem?.children ?? "select an item"} + )} + > @@ -134,8 +158,13 @@ Selects can be displayed using any of the `feel` values from `Button`. - ( + <>{selectedItem?.children ?? "select an item"} + )} + > @@ -224,6 +253,25 @@ The menu width can be fixed to match the trigger button size with the `matchTrig +## FormControl context + +`Select` is prepared to handle values passed from `FormControl` via `FormControlContext`. + +### Error State + + + + + + error message + + + + ## Props diff --git a/src/Select/index.tsx b/src/Select/index.tsx index acd95e85..621e23de 100644 --- a/src/Select/index.tsx +++ b/src/Select/index.tsx @@ -18,6 +18,7 @@ import { ListConfigProvider, useListConfig } from "../ListConfig"; import { As, createElementFromAs } from "../shared/createElementFromAs"; import useDeepCompareEffect from "use-deep-compare-effect"; import { inputHeightDictionary } from "../shared/inputHeightDictionary"; +import { useFormControlContext } from "../FormControl"; export type OptionProps = Omit< React.DetailedHTMLProps< @@ -78,13 +79,16 @@ interface Props | "popperOptions" | "matchTriggerWidth" >, - Pick, "feel" | "style">, + Pick< + React.ComponentProps, + "aria-labelledby" | "aria-describedby" | "feel" | "style" + >, Pick< React.DetailedHTMLProps< React.SelectHTMLAttributes, HTMLSelectElement >, - "onBlur" | "onChange" | "name" | "id" + "onBlur" | "onChange" | "name" > { /** * class name to apply to the trigger component @@ -126,6 +130,38 @@ interface Props labelPropsCallbackRef?: RefCallback< ReturnType["getLabelProps"]> >; + /** + * ID is an optional field used to formulaicly add accessability props as + * follows: + * + * - The trigger button will be given the this `id` + * - The list will be given ```${id}-menu``` + * + * If this field is not included or is `undefined`, the automatic downshift + * props will be used. + * + * The list and trigger button will also be assigned the value of + * `aria-labelledby` + */ + id?: string | undefined; + + /** + * Used to override how the underlying `List` is rendered + * + * This is useful when need to customize the list behavior + * + * @default + */ + listAs?: React.ReactElement>; + + /** + * Render prop function to generate a `React.ReactNode` based on the currently + * selected value. + * + * This is useful when you want some custom behavior with what is shown in the + * select in the unopened state. + */ + renderTriggerNode?: (value: OptionProps | null) => React.ReactNode; triggerAs?: As; @@ -151,16 +187,29 @@ export const Select: React.FC = ({ disabled = false, feel, labelPropsCallbackRef, + listAs = , matchTriggerWidth, onBlur, onChange, placement = "bottom-start", popperOptions, + renderTriggerNode = (value) => <>{value?.children || ""}, size = "standard", triggerAs =