From efcfe8f9f4a4ae90ceb74596cd78a5169c83b6fd Mon Sep 17 00:00:00 2001 From: Alex Page Date: Wed, 9 Jun 2021 14:25:03 -0700 Subject: [PATCH 1/5] Upgrade to node v12 (#4191) * Move to node v12 * Bump merge-deep from 3.0.2 to 3.0.3 (#4254) Bumps [merge-deep](https://github.com/jonschlinkert/merge-deep) from 3.0.2 to 3.0.3. - [Release notes](https://github.com/jonschlinkert/merge-deep/releases) - [Commits](https://github.com/jonschlinkert/merge-deep/compare/3.0.2...3.0.3) --- updated-dependencies: - dependency-name: merge-deep dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump ws in /examples/create-react-app-ts-react-testing (#4242) Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.2. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/commits) --- updated-dependencies: - dependency-name: ws dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump ws from 6.2.1 to 6.2.2 in /examples/create-react-app (#4241) Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.2. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/commits) --- updated-dependencies: - dependency-name: ws dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump ws from 6.2.1 to 6.2.2 in /examples/webpack (#4240) Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.2. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/commits) --- updated-dependencies: - dependency-name: ws dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update UNRELEASED.md Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .nvmrc | 2 +- UNRELEASED.md | 2 ++ dev.yml | 2 +- examples/create-react-app-ts-react-testing/yarn.lock | 6 +++--- examples/create-react-app/yarn.lock | 6 +++--- examples/webpack/yarn.lock | 6 +++--- yarn.lock | 6 +++--- 8 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 308409fedb4..a9ce69e3c2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: '10.24.0' + node-version: '12.13.0' - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -45,7 +45,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: '10.24.0' + node-version: '12.13.0' - name: Get yarn cache directory path id: yarn-cache-dir-path diff --git a/.nvmrc b/.nvmrc index e3653a9a7e9..bce43c253fe 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v10.24.0 +v12.13.0 diff --git a/UNRELEASED.md b/UNRELEASED.md index 2ffc1356bde..ecd8ecd1228 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,6 +4,8 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f ### Breaking changes +- Dropping support for node 10.x + ### Enhancements - Prevented `KeypressListener` attaching/detaching on every render ([#4173](https://github.com/Shopify/polaris-react/pull/4173)) diff --git a/dev.yml b/dev.yml index 154ed60b2cb..fe374973683 100644 --- a/dev.yml +++ b/dev.yml @@ -2,7 +2,7 @@ name: polaris-react up: - node: yarn: v1.13.0 - version: v10.24.0 # to be kept in sync with .nvmrc and ci.yml + version: v12.13.0 # to be kept in sync with .nvmrc and ci.yml - git_hooks: pre-commit: pre-commit diff --git a/examples/create-react-app-ts-react-testing/yarn.lock b/examples/create-react-app-ts-react-testing/yarn.lock index 142c1d69bbe..f1594cf641a 100644 --- a/examples/create-react-app-ts-react-testing/yarn.lock +++ b/examples/create-react-app-ts-react-testing/yarn.lock @@ -12080,9 +12080,9 @@ write-file-atomic@^3.0.0: typedarray-to-buffer "^3.1.5" ws@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" - integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== + version "6.2.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e" + integrity sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw== dependencies: async-limiter "~1.0.0" diff --git a/examples/create-react-app/yarn.lock b/examples/create-react-app/yarn.lock index 82dc37bfa73..e92bd277828 100644 --- a/examples/create-react-app/yarn.lock +++ b/examples/create-react-app/yarn.lock @@ -12279,9 +12279,9 @@ write-file-atomic@^3.0.0: typedarray-to-buffer "^3.1.5" ws@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" - integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== + version "6.2.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e" + integrity sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw== dependencies: async-limiter "~1.0.0" diff --git a/examples/webpack/yarn.lock b/examples/webpack/yarn.lock index 2a8b70a3f3c..1ef52618188 100644 --- a/examples/webpack/yarn.lock +++ b/examples/webpack/yarn.lock @@ -4918,9 +4918,9 @@ wrappy@1: integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= ws@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" - integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== + version "6.2.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e" + integrity sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw== dependencies: async-limiter "~1.0.0" diff --git a/yarn.lock b/yarn.lock index a620e04da34..7d739aba2ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13238,9 +13238,9 @@ meow@^7.1.1: yargs-parser "^18.1.3" merge-deep@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/merge-deep/-/merge-deep-3.0.2.tgz#f39fa100a4f1bd34ff29f7d2bf4508fbb8d83ad2" - integrity sha512-T7qC8kg4Zoti1cFd8Cr0M+qaZfOwjlPDEdZIIPPB2JZctjaPM4fX+i7HOId69tAti2fvO6X5ldfYUONDODsrkA== + version "3.0.3" + resolved "https://registry.yarnpkg.com/merge-deep/-/merge-deep-3.0.3.tgz#1a2b2ae926da8b2ae93a0ac15d90cd1922766003" + integrity sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA== dependencies: arr-union "^3.1.0" clone-deep "^0.2.4" From 1c36e5fa885f4ca854da8bbd81052b57e3228dd9 Mon Sep 17 00:00:00 2001 From: Alex Page Date: Thu, 24 Jun 2021 15:06:49 -0700 Subject: [PATCH 2/5] Bump react and react-dom to v16.14.0 (#4279) Co-authored-by: Ben Scott <227292+BPScott@users.noreply.github.com> --- UNRELEASED.md | 1 + package.json | 16 +++++------ yarn.lock | 80 +++++++++++++++++++++++++++++---------------------- 3 files changed, 55 insertions(+), 42 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 6024b27dcff..2ff6214bf7f 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -4,6 +4,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f ### Breaking changes +- Updated `react` and `react-dom` to version 16.14.0. This is now the minimum version of React required to use the `@shopify/polaris` library. - Dropping support for node 10.x ### Enhancements diff --git a/package.json b/package.json index 6298d4b0735..a26d5884ba9 100644 --- a/package.json +++ b/package.json @@ -78,8 +78,8 @@ "dependencies": { "@shopify/polaris-icons": "^4.1.0", "@shopify/polaris-tokens": "^3.0.0", - "@types/react": "^16.9.12", - "@types/react-dom": "^16.9.4", + "@types/react": "^16.14.8", + "@types/react-dom": "^16.9.13", "@types/react-transition-group": "^4.4.0", "focus-visible": "^5.2.0", "lodash": "^4.17.4", @@ -87,8 +87,8 @@ "serve": "^12.0.0" }, "peerDependencies": { - "react": "^16.9.0", - "react-dom": "^16.9.0" + "react": "^16.14.0", + "react-dom": "^16.14.0" }, "devDependencies": { "@babel/core": "^7.6.0", @@ -139,10 +139,10 @@ "object-hash": "^1.3.1", "postcss": "^7.0.18", "postcss-modules": "^3.1.0", - "react": "^16.9.0", - "react-dom": "^16.9.0", - "react-is": "^16.9.0", - "react-test-renderer": "^16.9.0", + "react": "^16.14.0", + "react-dom": "^16.14.0", + "react-is": "^16.7.2", + "react-test-renderer": "^16.14.0", "rimraf": "^3.0.0", "rollup": "^2.22.2", "sass-loader": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index 5e5f32d30d9..1ee7514d6de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4038,12 +4038,12 @@ dependencies: "@types/react" "*" -"@types/react-dom@^16.9.4": - version "16.9.4" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.4.tgz#0b58df09a60961dcb77f62d4f1832427513420df" - integrity sha512-fya9xteU/n90tda0s+FtN5Ym4tbgxpq/hb/Af24dvs6uYnYn+fspaxw5USlw0R8apDNwxsqumdRoCoKitckQqw== +"@types/react-dom@^16.9.13": + version "16.9.13" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.13.tgz#5898f0ee68fe200685e6b61d3d7d8828692814d0" + integrity sha512-34Hr3XnmUSJbUVDxIw/e7dhQn2BJZhJmlAaPyPwfTQyuVS9mV/CeyghFcXyvkJXxI7notQJz8mF8FeCVvloJrA== dependencies: - "@types/react" "*" + "@types/react" "^16" "@types/react-syntax-highlighter@11.0.4": version "11.0.4" @@ -4066,13 +4066,23 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.9.12": - version "16.9.16" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.16.tgz#4f12515707148b1f53a8eaa4341dae5dfefb066d" - integrity sha512-dQ3wlehuBbYlfvRXfF5G+5TbZF3xqgkikK7DWAsQXe2KnzV+kjD4W2ea+ThCrKASZn9h98bjjPzoTYzfRqyBkw== +"@types/react@*": + version "17.0.11" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451" + integrity sha512-yFRQbD+whVonItSk7ZzP/L+gPTJVBkL/7shLEF+i9GC/1cV3JmUxEQz6+9ylhUpWSDuqo1N9qEvqS6vTj4USUA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/react@^16", "@types/react@^16.14.8": + version "16.14.8" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.8.tgz#4aee3ab004cb98451917c9b7ada3c7d7e52db3fe" + integrity sha512-QN0/Qhmx+l4moe7WJuTxNiTsjBwlBGHqKGvInSQCBdo7Qio0VtOqwsC0Wq7q3PbJlB0cR4Y4CVo1OOe6BOsOmA== dependencies: "@types/prop-types" "*" - csstype "^2.2.0" + "@types/scheduler" "*" + csstype "^3.0.2" "@types/resolve@0.0.8": version "0.0.8" @@ -4088,6 +4098,11 @@ dependencies: "@types/node" "*" +"@types/scheduler@*": + version "0.16.1" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" + integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== + "@types/scss-parser@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/scss-parser/-/scss-parser-1.0.0.tgz#1f2a69880e475e5d31ed11e0b1c1eab22752935a" @@ -7479,11 +7494,16 @@ cssstyle@^2.0.0: dependencies: cssom "~0.3.6" -csstype@^2.2.0, csstype@^2.5.7, csstype@^2.6.7: +csstype@^2.5.7, csstype@^2.6.7: version "2.6.11" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.11.tgz#452f4d024149ecf260a852b025e36562a253ffc5" integrity sha512-l8YyEC9NBkSm783PFTvh0FmJy7s5pFKrDp49ZL7zBGX3fWkO+N4EEyan1qqp8cwPLDcD0OSdyY6hAMoxp34JFw== +csstype@^3.0.2: + version "3.0.8" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" + integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -15916,15 +15936,15 @@ react-docgen@^5.0.0: node-dir "^0.1.10" strip-indent "^3.0.0" -react-dom@^16.8.3, react-dom@^16.9.0: - version "16.9.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.9.0.tgz#5e65527a5e26f22ae3701131bcccaee9fb0d3962" - integrity sha512-YFT2rxO9hM70ewk9jq0y6sQk8cL02xm4+IzYBz75CQGlClQQ1Bxq0nhHF6OtSbit+AIahujJgb/CPRibFkMNJQ== +react-dom@^16.14.0, react-dom@^16.8.3: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" + integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.15.0" + scheduler "^0.19.1" react-draggable@^4.0.3: version "4.0.3" @@ -16017,12 +16037,12 @@ react-inspector@^5.0.1: is-dom "^1.1.0" prop-types "^15.6.1" -react-is@^16.12.0: +react-is@^16.12.0, react-is@^16.7.2: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6: version "16.9.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb" integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw== @@ -16143,10 +16163,10 @@ react-syntax-highlighter@^13.5.0: prismjs "^1.21.0" refractor "^3.1.0" -react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" - integrity sha512-Sn2VRyOK2YJJldOqoh8Tn/lWQ+ZiKhyZTPtaO0Q6yNj+QDbmRkVFap6pZPy3YQk8DScRDfyqm/KxKYP9gCMRiQ== +react-test-renderer@^16.0.0-0, react-test-renderer@^16.14.0: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.14.0.tgz#e98360087348e260c56d4fe2315e970480c228ae" + integrity sha512-L8yPjqPE5CZO6rKsKXRO/rVPiaCOy0tQQJbC+UjPNlobl5mad59lvPjwFsQHTvL03caVDIVr9x9/OSgDe6I5Eg== dependencies: object-assign "^4.1.1" prop-types "^15.6.2" @@ -16190,10 +16210,10 @@ react-transition-group@^4.4.1: loose-envify "^1.4.0" prop-types "^15.6.2" -react@^16.8.3, react@^16.9.0: - version "16.9.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.9.0.tgz#40ba2f9af13bc1a38d75dbf2f4359a5185c4f7aa" - integrity sha512-+7LQnFBwkiw+BobzOF6N//BdoNw0ouwmSJTEm9cglOOmsg/TMiFHZLe2sEoN5M7LgJTj9oHH0gxklfnQe66S1w== +react@^16.14.0, react@^16.8.3: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -17033,14 +17053,6 @@ scheduler@^0.13.6: loose-envify "^1.1.0" object-assign "^4.1.1" -scheduler@^0.15.0: - version "0.15.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.15.0.tgz#6bfcf80ff850b280fed4aeecc6513bc0b4f17f8e" - integrity sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - scheduler@^0.19.1: version "0.19.1" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" From e956a40a12fc0a1a9724cfe2b3045cfbca3b2318 Mon Sep 17 00:00:00 2001 From: Andrew McGoveran Date: Wed, 30 Jun 2021 10:27:47 -0400 Subject: [PATCH 3/5] [TextField] Make autoComplete prop a required string (#4267) * Require autoComplete prop on TextField * switch to autoComplete="off" as default * Adding off recommendation. * Fix link formatting issue in TextField/README.md Co-authored-by: Genevieve Bulger --- UNRELEASED.md | 1 + .../src/App.tsx | 3 + examples/create-react-app/src/App.js | 3 + examples/next.js/pages/index.js | 12 +- examples/webpack/src/App.js | 12 +- playground/DetailsPage.tsx | 4 + .../ComboBox/tests/ComboBox.test.tsx | 2 +- .../components/TextField/TextField.tsx | 2 +- .../Autocomplete/tests/Autocomplete.test.tsx | 4 +- src/components/ChoiceList/README.md | 2 + src/components/Filters/Filters.tsx | 1 + src/components/Filters/README.md | 9 + src/components/Form/README.md | 2 + src/components/FormLayout/README.md | 31 ++- .../components/Group/tests/Group.test.tsx | 2 +- .../components/Item/tests/Item.test.tsx | 4 +- .../FormLayout/tests/FormLayout.test.tsx | 4 +- src/components/Frame/README.md | 8 + src/components/IndexTable/README.md | 3 + src/components/Layout/README.md | 18 +- src/components/Modal/README.md | 1 + .../Pagination/tests/Pagination.test.tsx | 2 +- src/components/Popover/README.md | 1 + .../tests/PopoverOverlay.test.tsx | 10 +- src/components/RangeSlider/README.md | 2 + src/components/ResourceList/README.md | 3 + .../FilterControl/FilterControl.tsx | 1 + .../components/DateSelector/DateSelector.tsx | 2 +- .../FilterValueSelector.tsx | 1 + src/components/Select/README.md | 1 + src/components/Spinner/README.md | 1 + src/components/TextField/README.md | 64 +++++- src/components/TextField/TextField.tsx | 16 +- .../TextField/tests/TextField.test.tsx | 216 ++++++++++++++---- src/components/Tooltip/README.md | 2 +- .../TrapFocus/tests/TrapFocus.test.tsx | 26 ++- src/components/VisuallyHidden/README.md | 8 +- 37 files changed, 384 insertions(+), 100 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 2ff6214bf7f..2ec5de018c5 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -6,6 +6,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Updated `react` and `react-dom` to version 16.14.0. This is now the minimum version of React required to use the `@shopify/polaris` library. - Dropping support for node 10.x +- Made `autoComplete` prop in `TextField` a required string ([#4267](https://github.com/Shopify/polaris-react/pull/4267)). If you do not want the browser to autofill a user's information (for example an email input which is a customer's email, but not the email of the user who is entering the information), we recommend setting `autoComplete` to `"off"`. ### Enhancements diff --git a/examples/create-react-app-ts-react-testing/src/App.tsx b/examples/create-react-app-ts-react-testing/src/App.tsx index 3bdbb40f06a..b7a9e445fb6 100644 --- a/examples/create-react-app-ts-react-testing/src/App.tsx +++ b/examples/create-react-app-ts-react-testing/src/App.tsx @@ -99,12 +99,14 @@ export function App() { label="First name" placeholder="Tom" onChange={handleFirstChange} + autoComplete="given-name" /> @@ -113,6 +115,7 @@ export function App() { label="Email" placeholder="example@email.com" onChange={handleEmailChange} + autoComplete="email" /> @@ -106,6 +108,7 @@ export function App() { label="Email" placeholder="example@email.com" onChange={handleEmailChange} + autoComplete="email" /> { - setConnected(!connected); - }, - [connected], - ); + const toggleConnection = useCallback(() => { + setConnected(!connected); + }, [connected]); const breadcrumbs = [{content: 'Sample apps'}, {content: 'next.js'}]; const primaryAction = {content: 'New product'}; @@ -95,12 +92,14 @@ export default function App() { label="First name" placeholder="Tom" onChange={handleFirstChange} + autoComplete="given-name" /> @@ -109,6 +108,7 @@ export default function App() { label="Email" placeholder="example@email.com" onChange={handleEmailChange} + autoComplete="email" /> { - setConnected(!connected); - }, - [connected], - ); + const toggleConnection = useCallback(() => { + setConnected(!connected); + }, [connected]); const breadcrumbs = [{content: 'Sample apps'}, {content: 'webpack'}]; const primaryAction = {content: 'New product'}; @@ -95,12 +92,14 @@ export default function App() { label="First name" placeholder="Tom" onChange={handleFirstChange} + autoComplete="given-name" /> @@ -109,6 +108,7 @@ export default function App() { label="Email" placeholder="example@email.com" onChange={handleEmailChange} + autoComplete="email" /> setIsDirty(true)} + autoComplete="off" /> @@ -653,11 +655,13 @@ export function DetailsPage() { label="Subject" value={supportSubject} onChange={handleSubjectChange} + autoComplete="off" /> diff --git a/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx b/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx index dd7de0edffc..44e5be9514b 100644 --- a/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx +++ b/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx @@ -621,7 +621,7 @@ describe('', () => { function noop() {} function renderTextField() { - return ; + return ; } function renderNodeWithId() { diff --git a/src/components/Autocomplete/components/TextField/TextField.tsx b/src/components/Autocomplete/components/TextField/TextField.tsx index 5597c8b7988..880fe3513da 100644 --- a/src/components/Autocomplete/components/TextField/TextField.tsx +++ b/src/components/Autocomplete/components/TextField/TextField.tsx @@ -11,7 +11,7 @@ export function TextField(props: TextFieldProps) { ', () => { function noop() {} function renderTextField() { - return ; + return ( + + ); } function handleOnSelect(this: any, updatedSelection: string[]) { diff --git a/src/components/ChoiceList/README.md b/src/components/ChoiceList/README.md index e63693e2903..87b417f0cc2 100644 --- a/src/components/ChoiceList/README.md +++ b/src/components/ChoiceList/README.md @@ -322,6 +322,7 @@ function SingleOrMultiChoiceListWithChildrenContextExample() { labelHidden onChange={handleTextFieldChange} value={textFieldValue} + autoComplete="off" /> ), [handleTextFieldChange, textFieldValue], @@ -372,6 +373,7 @@ function SingleOrMultuChoiceListWithChildrenContextWhenSelectedExample() { labelHidden onChange={handleTextFieldChange} value={textFieldValue} + autoComplete="off" /> ), [handleTextFieldChange, textFieldValue], diff --git a/src/components/Filters/Filters.tsx b/src/components/Filters/Filters.tsx index ad65dab43d2..a9efe1bf107 100644 --- a/src/components/Filters/Filters.tsx +++ b/src/components/Filters/Filters.tsx @@ -287,6 +287,7 @@ class FiltersInner extends Component { clearButton onClearButtonClick={onQueryClear} disabled={disabled} + autoComplete="off" /> )} diff --git a/src/components/Filters/README.md b/src/components/Filters/README.md index e4880e96c7e..fcfb3d4d003 100644 --- a/src/components/Filters/README.md +++ b/src/components/Filters/README.md @@ -211,6 +211,7 @@ function ResourceListFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -423,6 +424,7 @@ function DataTableFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -555,6 +557,7 @@ function FiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -681,6 +684,7 @@ function DisableAllFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -816,6 +820,7 @@ function DisableSomeFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -829,6 +834,7 @@ function DisableSomeFiltersExample() { label="Vendor" value={vendor} onChange={handleVendorChange} + autoComplete="off" labelHidden /> ), @@ -960,6 +966,7 @@ function Playground() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -1131,6 +1138,7 @@ function ResourceListFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -1331,6 +1339,7 @@ function ResourceListFiltersExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), diff --git a/src/components/Form/README.md b/src/components/Form/README.md index 15ba3eefef2..4faf1c6b79f 100644 --- a/src/components/Form/README.md +++ b/src/components/Form/README.md @@ -65,6 +65,7 @@ function FormOnSubmitExample() { onChange={handleEmailChange} label="Email" type="email" + autoComplete="email" helpText={ We’ll use this email address to inform you on future changes to @@ -100,6 +101,7 @@ function FormWithoutNativeValidationExample() { onChange={handleUrlChange} label="App URL" type="url" + autoComplete="off" /> diff --git a/src/components/FormLayout/README.md b/src/components/FormLayout/README.md index 0a56066ecd1..7e0870bf5c7 100644 --- a/src/components/FormLayout/README.md +++ b/src/components/FormLayout/README.md @@ -102,8 +102,13 @@ Use to stack form fields vertically, which makes them easier to scan and complet ```jsx - {}} /> - {}} /> + {}} autoComplete="off" /> + {}} + autoComplete="email" + /> ``` @@ -130,8 +135,18 @@ Field groups will wrap automatically on smaller screens. ```jsx - {}} /> - {}} /> + {}} + autoComplete="off" + /> + {}} + autoComplete="off" + /> ``` @@ -157,10 +172,10 @@ For very short inputs, the width of the inputs may be reduced in order to fit mo ```jsx - {}} /> - {}} /> - {}} /> - {}} /> + {}} autoComplete="off" /> + {}} autoComplete="off" /> + {}} autoComplete="off" /> + {}} autoComplete="off" /> ``` diff --git a/src/components/FormLayout/components/Group/tests/Group.test.tsx b/src/components/FormLayout/components/Group/tests/Group.test.tsx index ef592a2fbcc..dbe1e5cb3a7 100644 --- a/src/components/FormLayout/components/Group/tests/Group.test.tsx +++ b/src/components/FormLayout/components/Group/tests/Group.test.tsx @@ -12,7 +12,7 @@ describe('', () => { let item: any; beforeAll(() => { - children = ; + children = ; title = 'Title'; helpText = 'Help text'; item = mountWithAppProvider( diff --git a/src/components/FormLayout/components/Item/tests/Item.test.tsx b/src/components/FormLayout/components/Item/tests/Item.test.tsx index cb7d28448cc..895f980beb6 100644 --- a/src/components/FormLayout/components/Item/tests/Item.test.tsx +++ b/src/components/FormLayout/components/Item/tests/Item.test.tsx @@ -7,7 +7,9 @@ import {Item} from '../Item'; describe('', () => { it('renders its children', () => { - const children = ; + const children = ( + + ); const item = mountWithAppProvider({children}); expect(item.contains(children)).toBe(true); }); diff --git a/src/components/FormLayout/tests/FormLayout.test.tsx b/src/components/FormLayout/tests/FormLayout.test.tsx index 016dc0ca970..f79ce9a08c5 100644 --- a/src/components/FormLayout/tests/FormLayout.test.tsx +++ b/src/components/FormLayout/tests/FormLayout.test.tsx @@ -7,7 +7,9 @@ import {FormLayout} from '../FormLayout'; describe('', () => { it('renders its children', () => { - const children = ; + const children = ( + + ); const formLayout = mountWithAppProvider( {children}, ); diff --git a/src/components/Frame/README.md b/src/components/Frame/README.md index 2725d9c6c9c..bde4a651b9c 100644 --- a/src/components/Frame/README.md +++ b/src/components/Frame/README.md @@ -245,12 +245,14 @@ function FrameExample() { label="Full name" value={nameFieldValue} onChange={handleNameFieldChange} + autoComplete="name" /> @@ -292,11 +294,13 @@ function FrameExample() { label="Subject" value={supportSubject} onChange={handleSubjectChange} + autoComplete="off" /> @@ -579,12 +583,14 @@ function FrameExample() { label="Full name" value={nameFieldValue} onChange={handleNameFieldChange} + autoComplete="name" /> @@ -626,11 +632,13 @@ function FrameExample() { label="Subject" value={supportSubject} onChange={handleSubjectChange} + autoComplete="off" /> diff --git a/src/components/IndexTable/README.md b/src/components/IndexTable/README.md index 7c69e72bb47..46c3b1c75a0 100644 --- a/src/components/IndexTable/README.md +++ b/src/components/IndexTable/README.md @@ -603,6 +603,7 @@ function IndexTableWithFilteringExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -864,6 +865,7 @@ function IndexTableWithAllElementsExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -1049,6 +1051,7 @@ function SmallScreenIndexTableWithAllElementsExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), diff --git a/src/components/Layout/README.md b/src/components/Layout/README.md index af5a543b6a1..983d61cb9be 100644 --- a/src/components/Layout/README.md +++ b/src/components/Layout/README.md @@ -416,8 +416,13 @@ Use for settings pages. When settings are grouped thematically in annotated sect > - {}} /> - {}} /> + {}} autoComplete="off" /> + {}} + autoComplete="email" + /> @@ -441,8 +446,13 @@ Use for settings pages that need a banner or other content at the top. > - {}} /> - {}} /> + {}} autoComplete="off" /> + {}} + autoComplete="email" + /> diff --git a/src/components/Modal/README.md b/src/components/Modal/README.md index 35500d4c463..9bf36b32b41 100644 --- a/src/components/Modal/README.md +++ b/src/components/Modal/README.md @@ -369,6 +369,7 @@ function ModalWithPrimaryActionExample() { onFocus={handleFocus} value={DISCOUNT_LINK} onChange={() => {}} + autoComplete="off" connectedRight={ diff --git a/src/components/Popover/components/PopoverOverlay/tests/PopoverOverlay.test.tsx b/src/components/Popover/components/PopoverOverlay/tests/PopoverOverlay.test.tsx index 34255fe5b3e..917fd515381 100644 --- a/src/components/Popover/components/PopoverOverlay/tests/PopoverOverlay.test.tsx +++ b/src/components/Popover/components/PopoverOverlay/tests/PopoverOverlay.test.tsx @@ -236,7 +236,14 @@ describe('', () => { activator={activator} onClose={spy} > - ( {}} />) + ( + {}} + autoComplete="off" + /> + ) , ); @@ -267,6 +274,7 @@ describe('', () => { label="Store name" value="Click me" onChange={() => {}} + autoComplete="off" /> ) , diff --git a/src/components/RangeSlider/README.md b/src/components/RangeSlider/README.md index 07b4dc7efe6..a611bb2169f 100644 --- a/src/components/RangeSlider/README.md +++ b/src/components/RangeSlider/README.md @@ -352,6 +352,7 @@ function DualThumbRangeSliderExample() { step={step} onChange={handleLowerTextFieldChange} onBlur={handleLowerTextFieldBlur} + autoComplete="off" /> diff --git a/src/components/ResourceList/README.md b/src/components/ResourceList/README.md index 0dfbc97453e..4a8baa5eb01 100644 --- a/src/components/ResourceList/README.md +++ b/src/components/ResourceList/README.md @@ -595,6 +595,7 @@ function ResourceListWithFilteringExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -710,6 +711,7 @@ function ResourceListWithFilteringExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), @@ -1094,6 +1096,7 @@ function ResourceListExample() { label="Tagged with" value={taggedWith} onChange={handleTaggedWithChange} + autoComplete="off" labelHidden /> ), diff --git a/src/components/ResourceList/components/FilterControl/FilterControl.tsx b/src/components/ResourceList/components/FilterControl/FilterControl.tsx index e88283077ad..ed5d4808b15 100644 --- a/src/components/ResourceList/components/FilterControl/FilterControl.tsx +++ b/src/components/ResourceList/components/FilterControl/FilterControl.tsx @@ -159,6 +159,7 @@ export function FilterControl({ onBlur={onSearchBlur} focused={focused} disabled={selectMode} + autoComplete="off" /> {appliedFiltersWrapper} diff --git a/src/components/ResourceList/components/FilterControl/components/DateSelector/DateSelector.tsx b/src/components/ResourceList/components/FilterControl/components/DateSelector/DateSelector.tsx index df2a817bead..e4f99bf8dc3 100644 --- a/src/components/ResourceList/components/FilterControl/components/DateSelector/DateSelector.tsx +++ b/src/components/ResourceList/components/FilterControl/components/DateSelector/DateSelector.tsx @@ -202,7 +202,7 @@ export const DateSelector = memo(function DateSelector({ value={dateTextFieldValue} error={userInputDateError} prefix={} - autoComplete={false} + autoComplete="off" onChange={handleDateFieldChange} onBlur={handleDateBlur} /> diff --git a/src/components/ResourceList/components/FilterControl/components/FilterValueSelector/FilterValueSelector.tsx b/src/components/ResourceList/components/FilterControl/components/FilterValueSelector/FilterValueSelector.tsx index ef0c53bf5b0..e07e467b5d8 100644 --- a/src/components/ResourceList/components/FilterControl/components/FilterValueSelector/FilterValueSelector.tsx +++ b/src/components/ResourceList/components/FilterControl/components/FilterValueSelector/FilterValueSelector.tsx @@ -86,6 +86,7 @@ export function FilterValueSelector({ value={value} type={filter.textFieldType} onChange={onChange} + autoComplete="off" /> ); diff --git a/src/components/Select/README.md b/src/components/Select/README.md index 778f35b1e93..c2c8baa41c3 100644 --- a/src/components/Select/README.md +++ b/src/components/Select/README.md @@ -294,6 +294,7 @@ function SeparateValidationErrorExample() { value={weight} onChange={handleWeightChange} error={Boolean(!weight && unit)} + autoComplete="off" /> ); } @@ -400,6 +451,7 @@ function RightAlignExample() { labelHidden value={textFieldValue} onChange={handleTextFieldChange} + autoComplete="off" align="right" /> @@ -426,6 +478,7 @@ function PlaceholderExample() { value={textFieldValue} onChange={handleTextFieldChange} placeholder="Example: North America, Europe" + autoComplete="off" /> ); } @@ -465,6 +518,7 @@ function HelpTextExample() { value={textFieldValue} onChange={handleTextFieldChange} helpText="We’ll use this address if we need to contact you about your account." + autoComplete="email" /> ); } @@ -505,6 +559,7 @@ function PrefixExample() { value={textFieldValue} onChange={handleTextFieldChange} prefix="$" + autoComplete="off" /> ); } @@ -550,6 +605,7 @@ function ConnectedFieldsExample() { type="number" value={textFieldValue} onChange={handleTextFieldChange} + autoComplete="off" connectedLeft={ `onClick`', () => { const textField = mountWithApp( - , + , ); expect(document.activeElement).not.toBe(textField.find('input')!.domNode); @@ -1062,6 +1166,7 @@ describe('', () => { onChange={noop} type="text" value="test value" + autoComplete="off" clearButton />, ); @@ -1075,6 +1180,7 @@ describe('', () => { label="TextField" type="text" onChange={noop} + autoComplete="off" clearButton />, ); @@ -1094,6 +1200,7 @@ describe('', () => { onChange={noop} onClearButtonClick={spy} value="test value" + autoComplete="off" clearButton />, ); @@ -1109,6 +1216,7 @@ describe('', () => { onChange={noop} type="text" value="test value" + autoComplete="off" />, ); expect(findByTestID(textField, 'clearButton').exists()).toBeFalsy(); @@ -1121,6 +1229,7 @@ describe('', () => { onChange={noop} connectedLeft={
} connectedRight={
} + autoComplete="off" />, ); expect(textField).toContainReactComponent('div', { @@ -1132,7 +1241,12 @@ describe('', () => { describe('requiredIndicator', () => { it('passes requiredIndicator prop to Labelled', () => { const element = mountWithAppProvider( - , + , ); const labelled = element.find(Labelled); @@ -1143,14 +1257,24 @@ describe('', () => { describe('monospaced', () => { it('passes monospaced prop to TextField', () => { const element = mountWithAppProvider( - , + , ); expect(element.prop('monospaced')).toBe(true); }); it('applies the monospaced style', () => { const input = mountWithAppProvider( - , + , ).find('input'); expect(input.prop('className')).toContain('monospaced'); diff --git a/src/components/Tooltip/README.md b/src/components/Tooltip/README.md index e8b9cb81071..e91a2bc8490 100644 --- a/src/components/Tooltip/README.md +++ b/src/components/Tooltip/README.md @@ -94,7 +94,7 @@ Use when the tooltip overlays interactive elements when active, for example a fo - +
``` diff --git a/src/components/TrapFocus/tests/TrapFocus.test.tsx b/src/components/TrapFocus/tests/TrapFocus.test.tsx index 9d4711508d3..0ab42aecee8 100644 --- a/src/components/TrapFocus/tests/TrapFocus.test.tsx +++ b/src/components/TrapFocus/tests/TrapFocus.test.tsx @@ -113,7 +113,13 @@ describe('', () => { const trapFocus = mountWithApp( - + , ); @@ -124,7 +130,7 @@ describe('', () => { const trapFocus = mountWithApp( - + , ); @@ -159,7 +165,13 @@ describe('', () => { it('allows default when trapping is false', () => { const trapFocus = mountWithApp( - + , ); @@ -175,7 +187,13 @@ describe('', () => { it('allows default when the related target is a child', () => { const trapFocus = mountWithApp( - + , ); diff --git a/src/components/VisuallyHidden/README.md b/src/components/VisuallyHidden/README.md index bf47e597366..a7c39d5d539 100644 --- a/src/components/VisuallyHidden/README.md +++ b/src/components/VisuallyHidden/README.md @@ -51,8 +51,14 @@ Always provide a heading for a major page section such as a card. In rare cases label="Title" value="Artisanal Wooden Spoon" onChange={() => {}} + autoComplete="off" + /> + {}} + autoComplete="off" /> - {}} /> ``` From 74241bb6ca0641e43d9fa78b76a53d9227dafc5c Mon Sep 17 00:00:00 2001 From: Ben Scott <227292+BPScott@users.noreply.github.com> Date: Mon, 12 Jul 2021 12:53:40 -0700 Subject: [PATCH 4/5] Update browserslist to match shopify/browserslist-config v3.0.0 (#4304) - Desktop Safari version is "last 3 safari versions" - mapping to Safari 13.1 - ios Safari version is "ios >= 13.4" - mapping to ios_saf 13.4-13.7 --- UNRELEASED.md | 1 + package.json | 9 +++++---- yarn.lock | 13 ++++--------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 2ec5de018c5..1fe7c367b6a 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -6,6 +6,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Updated `react` and `react-dom` to version 16.14.0. This is now the minimum version of React required to use the `@shopify/polaris` library. - Dropping support for node 10.x +- Dropped support for Desktop Safari versions less than 13.1, and ios Safari versions less than 13.6. ([#4304](https://github.com/Shopify/polaris-react/pull/4304)) - Made `autoComplete` prop in `TextField` a required string ([#4267](https://github.com/Shopify/polaris-react/pull/4267)). If you do not want the browser to autofill a user's information (for example an email input which is a customer's email, but not the email of the user who is entering the information), we recommend setting `autoComplete` to `"off"`. ### Enhancements diff --git a/package.json b/package.json index a26d5884ba9..1302150ca7f 100644 --- a/package.json +++ b/package.json @@ -156,12 +156,13 @@ }, "browserslist": [ "last 3 chrome versions", - "last 3 chromeandroid versions", "last 3 firefox versions", "last 3 opera versions", - "last 2 edge versions", - "safari >= 10", - "ios >= 10" + "last 3 edge versions", + "last 3 safari versions", + "last 3 chromeandroid versions", + "last 1 firefoxandroid versions", + "ios >= 13.4" ], "prettier": "@shopify/prettier-config", "stylelint": { diff --git a/yarn.lock b/yarn.lock index 1ee7514d6de..9befef8bdb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6322,15 +6322,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001010, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001135: - version "1.0.30001151" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001151.tgz#1ddfde5e6fff02aad7940b4edb7d3ac76b0cb00b" - integrity sha512-Zh3sHqskX6mHNrqUerh+fkf0N72cMxrmflzje/JyVImfpknscMnkeJrlFGJcqTmaa0iszdYptGpWMJCRQDkBVw== - -caniuse-lite@^1.0.30001173: - version "1.0.30001177" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001177.tgz#2c3b384933aafda03e29ccca7bb3d8c3389e1ece" - integrity sha512-6Ld7t3ifCL02jTj3MxPMM5wAYjbo4h/TAQGFTgv1inihP1tWnWp8mxxT4ut4JBEHLbpFXEXJJQ119JCJTBkYDw== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001010, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001135, caniuse-lite@^1.0.30001173: + version "1.0.30001243" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz#d9250155c91e872186671c523f3ae50cfc94a3aa" + integrity sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA== capital-case@^1.0.3: version "1.0.3" From 9654c8017b66c89cf6bf638953c22978e32aae57 Mon Sep 17 00:00:00 2001 From: Christopher Poirier Date: Wed, 14 Jul 2021 18:41:53 -0400 Subject: [PATCH 5/5] [AutoComplete] Rebuild autocomplete internals to use new accessible components (#3910) * Rebuild AutoComplete internals to fix accessibility issues - Replace internals with new combobox and list box - Add support for legacy combobox - Allow TextField and Checkbox to leverage new combobox - Include additional test coverage for autocomplete and new components Co-authored-by: Andrew Musgrave Co-authored-by: Daniel Leroux * Fix some accessibility warnings on listbox stories * Remove listbox section example Co-authored-by: Andrew Musgrave Co-authored-by: Daniel Leroux --- .storybook/polaris-readme-loader.js | 2 + UNRELEASED.md | 4 + locales/en.json | 3 +- package.json | 2 +- src/components/Autocomplete/Autocomplete.tsx | 123 +++- src/components/Autocomplete/README.md | 233 +++++- .../components/ComboBox/ComboBox.scss | 5 - .../components/ComboBox/ComboBox.tsx | 378 ---------- .../components/ComboBox/context.tsx | 8 - .../Autocomplete/components/ComboBox/index.ts | 1 - .../ComboBox/tests/ComboBox.test.tsx | 639 ----------------- .../components/MappedAction/MappedAction.scss | 112 +++ .../components/MappedAction/MappedAction.tsx | 122 ++++ .../components/MappedAction/index.ts | 1 + .../MappedAction/tests/MappedAction.test.tsx | 184 +++++ .../components/MappedOption/MappedOption.scss | 22 + .../components/MappedOption/MappedOption.tsx | 51 ++ .../components/MappedOption/index.ts | 1 + .../MappedOption/tests/MappedOption.test.tsx | 92 +++ .../components/TextField/TextField.tsx | 22 - .../components/TextField/index.ts | 1 - .../Autocomplete/components/index.ts | 5 +- .../Autocomplete/tests/Autocomplete.test.tsx | 509 ++++++++++--- src/components/Checkbox/Checkbox.tsx | 14 +- src/components/ComboBox/ComboBox.scss | 6 + src/components/ComboBox/ComboBox.tsx | 153 ++++ src/components/ComboBox/README.md | 420 +++++++++++ .../components/TextField/TextField.tsx | 78 ++ .../ComboBox/components/TextField/index.ts | 1 + .../TextField/tests/TextField.test.tsx | 254 +++++++ src/components/ComboBox/components/index.ts | 1 + src/components/ComboBox/index.ts | 2 + .../ComboBox/tests/ComboBox.test.tsx | 373 ++++++++++ src/components/ListBox/ListBox.scss | 10 + src/components/ListBox/ListBox.tsx | 330 +++++++++ src/components/ListBox/README.md | 180 +++++ .../ListBox/components/Action/Action.scss | 10 + .../ListBox/components/Action/Action.tsx | 33 + .../ListBox/components/Action/index.ts | 1 + .../components/Action/tests/Action.test.tsx | 61 ++ .../ListBox/components/Header/Header.scss | 7 + .../ListBox/components/Header/Header.tsx | 26 + .../ListBox/components/Header/index.ts | 1 + .../components/Header/tests/Header.test.tsx | 55 ++ .../ListBox/components/Loading/Loading.scss | 14 + .../ListBox/components/Loading/Loading.tsx | 37 + .../ListBox/components/Loading/index.ts | 1 + .../components/Loading/tests/Loading.test.tsx | 98 +++ .../ListBox/components/Option/Option.scss | 15 + .../ListBox/components/Option/Option.tsx | 110 +++ .../ListBox/components/Option/index.ts | 1 + .../components/Option/tests/Option.test.tsx | 380 ++++++++++ .../ListBox/components/Section/Section.scss | 12 + .../ListBox/components/Section/Section.tsx | 36 + .../ListBox/components/Section/context.ts | 3 + .../ListBox/components/Section/hooks.ts | 8 + .../ListBox/components/Section/index.ts | 3 + .../ListBox/components/Section/selectors.ts | 8 + .../components/Section/tests/Section.test.tsx | 67 ++ .../components/TextOption/TextOption.scss | 71 ++ .../components/TextOption/TextOption.tsx | 43 ++ .../ListBox/components/TextOption/index.ts | 1 + .../TextOption/tests/TextOption.test.tsx | 51 ++ src/components/ListBox/components/index.ts | 7 + src/components/ListBox/index.ts | 1 + src/components/ListBox/tests/ListBox.test.tsx | 669 ++++++++++++++++++ src/components/TextField/README.md | 8 - src/components/index.ts | 6 + src/test-utilities/list-box.tsx | 59 ++ src/test-utilities/react-testing.tsx | 9 +- src/types.ts | 2 + src/utilities/autocomplete/context.ts | 14 + src/utilities/autocomplete/index.ts | 1 + src/utilities/closest-parent-match.ts | 10 + src/utilities/combo-box/context.tsx | 52 ++ src/utilities/combo-box/hooks.tsx | 18 + src/utilities/combo-box/index.ts | 2 + src/utilities/combo-box/tests/hook.test.tsx | 41 ++ src/utilities/list-box/context.ts | 14 + src/utilities/list-box/hooks.ts | 15 + src/utilities/list-box/index.ts | 3 + src/utilities/list-box/types.ts | 6 + src/utilities/scroll-into-view.ts | 8 + .../tests/closest-parent-match.test.ts | 30 + tests/setup.ts | 4 +- 85 files changed, 5263 insertions(+), 1211 deletions(-) delete mode 100644 src/components/Autocomplete/components/ComboBox/ComboBox.scss delete mode 100644 src/components/Autocomplete/components/ComboBox/ComboBox.tsx delete mode 100644 src/components/Autocomplete/components/ComboBox/context.tsx delete mode 100644 src/components/Autocomplete/components/ComboBox/index.ts delete mode 100644 src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx create mode 100644 src/components/Autocomplete/components/MappedAction/MappedAction.scss create mode 100644 src/components/Autocomplete/components/MappedAction/MappedAction.tsx create mode 100644 src/components/Autocomplete/components/MappedAction/index.ts create mode 100644 src/components/Autocomplete/components/MappedAction/tests/MappedAction.test.tsx create mode 100644 src/components/Autocomplete/components/MappedOption/MappedOption.scss create mode 100644 src/components/Autocomplete/components/MappedOption/MappedOption.tsx create mode 100644 src/components/Autocomplete/components/MappedOption/index.ts create mode 100644 src/components/Autocomplete/components/MappedOption/tests/MappedOption.test.tsx delete mode 100644 src/components/Autocomplete/components/TextField/TextField.tsx delete mode 100644 src/components/Autocomplete/components/TextField/index.ts create mode 100644 src/components/ComboBox/ComboBox.scss create mode 100644 src/components/ComboBox/ComboBox.tsx create mode 100644 src/components/ComboBox/README.md create mode 100644 src/components/ComboBox/components/TextField/TextField.tsx create mode 100644 src/components/ComboBox/components/TextField/index.ts create mode 100644 src/components/ComboBox/components/TextField/tests/TextField.test.tsx create mode 100644 src/components/ComboBox/components/index.ts create mode 100644 src/components/ComboBox/index.ts create mode 100644 src/components/ComboBox/tests/ComboBox.test.tsx create mode 100644 src/components/ListBox/ListBox.scss create mode 100644 src/components/ListBox/ListBox.tsx create mode 100644 src/components/ListBox/README.md create mode 100644 src/components/ListBox/components/Action/Action.scss create mode 100644 src/components/ListBox/components/Action/Action.tsx create mode 100644 src/components/ListBox/components/Action/index.ts create mode 100644 src/components/ListBox/components/Action/tests/Action.test.tsx create mode 100644 src/components/ListBox/components/Header/Header.scss create mode 100644 src/components/ListBox/components/Header/Header.tsx create mode 100644 src/components/ListBox/components/Header/index.ts create mode 100644 src/components/ListBox/components/Header/tests/Header.test.tsx create mode 100644 src/components/ListBox/components/Loading/Loading.scss create mode 100644 src/components/ListBox/components/Loading/Loading.tsx create mode 100644 src/components/ListBox/components/Loading/index.ts create mode 100644 src/components/ListBox/components/Loading/tests/Loading.test.tsx create mode 100644 src/components/ListBox/components/Option/Option.scss create mode 100644 src/components/ListBox/components/Option/Option.tsx create mode 100644 src/components/ListBox/components/Option/index.ts create mode 100644 src/components/ListBox/components/Option/tests/Option.test.tsx create mode 100644 src/components/ListBox/components/Section/Section.scss create mode 100644 src/components/ListBox/components/Section/Section.tsx create mode 100644 src/components/ListBox/components/Section/context.ts create mode 100644 src/components/ListBox/components/Section/hooks.ts create mode 100644 src/components/ListBox/components/Section/index.ts create mode 100644 src/components/ListBox/components/Section/selectors.ts create mode 100644 src/components/ListBox/components/Section/tests/Section.test.tsx create mode 100644 src/components/ListBox/components/TextOption/TextOption.scss create mode 100644 src/components/ListBox/components/TextOption/TextOption.tsx create mode 100644 src/components/ListBox/components/TextOption/index.ts create mode 100644 src/components/ListBox/components/TextOption/tests/TextOption.test.tsx create mode 100644 src/components/ListBox/components/index.ts create mode 100644 src/components/ListBox/index.ts create mode 100644 src/components/ListBox/tests/ListBox.test.tsx create mode 100644 src/test-utilities/list-box.tsx create mode 100644 src/utilities/autocomplete/context.ts create mode 100644 src/utilities/autocomplete/index.ts create mode 100644 src/utilities/closest-parent-match.ts create mode 100644 src/utilities/combo-box/context.tsx create mode 100644 src/utilities/combo-box/hooks.tsx create mode 100644 src/utilities/combo-box/index.ts create mode 100644 src/utilities/combo-box/tests/hook.test.tsx create mode 100644 src/utilities/list-box/context.ts create mode 100644 src/utilities/list-box/hooks.ts create mode 100644 src/utilities/list-box/index.ts create mode 100644 src/utilities/list-box/types.ts create mode 100644 src/utilities/scroll-into-view.ts create mode 100644 src/utilities/tests/closest-parent-match.test.ts diff --git a/.storybook/polaris-readme-loader.js b/.storybook/polaris-readme-loader.js index 5c24dc6b889..90916ac44d3 100644 --- a/.storybook/polaris-readme-loader.js +++ b/.storybook/polaris-readme-loader.js @@ -75,6 +75,7 @@ import { ChoiceList, Collapsible, ColorPicker, + ComboBox, Connected, ContextualSaveBar, DataTable, @@ -105,6 +106,7 @@ import { Layout, Link, List, + ListBox, Loading, MediaCard, Modal, diff --git a/UNRELEASED.md b/UNRELEASED.md index 1fe7c367b6a..389942187e8 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -8,6 +8,8 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Dropping support for node 10.x - Dropped support for Desktop Safari versions less than 13.1, and ios Safari versions less than 13.6. ([#4304](https://github.com/Shopify/polaris-react/pull/4304)) - Made `autoComplete` prop in `TextField` a required string ([#4267](https://github.com/Shopify/polaris-react/pull/4267)). If you do not want the browser to autofill a user's information (for example an email input which is a customer's email, but not the email of the user who is entering the information), we recommend setting `autoComplete` to `"off"`. +- `Autocomplete` now requires `Autocomplete.TextField` to be used ([#3910](https://github.com/Shopify/polaris-react/pull/3910)) +- Removed ComboBox as a named export on `Autocomplete` ([#3910](https://github.com/Shopify/polaris-react/pull/3910)) ### Enhancements @@ -34,4 +36,6 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f ### Code quality +- Rebuilt `Autocomplete` internals using new `ComboBox` and `ListBox` components built on the ARIA 1.2 spec for improved accessibility ([#3910](https://github.com/Shopify/polaris-react/pull/3910)) + ### Deprecations diff --git a/locales/en.json b/locales/en.json index 5d7fc27fa14..4f2163cde11 100644 --- a/locales/en.json +++ b/locales/en.json @@ -8,7 +8,8 @@ "labelWithInitials": "Avatar with initials {initials}" }, "Autocomplete": { - "spinnerAccessibilityLabel": "Loading" + "spinnerAccessibilityLabel": "Loading", + "ellipsis": "{content}…" }, "Badge": { "PROGRESS_LABELS": { diff --git a/package.json b/package.json index 1302150ca7f..bad553fae50 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,7 @@ { "name": "esm", "path": "dist/esm/index.js", - "limit": "100 kB" + "limit": "105 kB" }, { "name": "esnext", diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index b0dd2deee7d..f9d163a2e2f 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -1,23 +1,25 @@ -import React from 'react'; +import React, {useMemo, useCallback} from 'react'; -import {useI18n} from '../../utilities/i18n'; import type {ActionListItemDescriptor} from '../../types'; -import {Spinner} from '../Spinner'; +import type {OptionDescriptor} from '../OptionList'; +import type {PopoverProps} from '../Popover'; +import {useI18n} from '../../utilities/i18n'; +import {ComboBox} from '../ComboBox'; +import {ListBox} from '../ListBox'; -import {TextField, ComboBox, ComboBoxProps} from './components'; -import styles from './Autocomplete.scss'; +import {MappedOption, MappedAction} from './components'; export interface AutocompleteProps { /** A unique identifier for the Autocomplete */ id?: string; /** Collection of options to be listed */ - options: ComboBoxProps['options']; + options: OptionDescriptor[]; /** The selected options */ selected: string[]; /** The text field component attached to the list of options */ textField: React.ReactElement; /** The preferred direction to open the popover */ - preferredPosition?: ComboBoxProps['preferredPosition']; + preferredPosition?: PopoverProps['preferredPosition']; /** Title of the list of options */ listTitle?: string; /** Allow more than one option to be selected */ @@ -42,10 +44,8 @@ export interface AutocompleteProps { // generated *.d.ts files. export const Autocomplete: React.FunctionComponent & { - ComboBox: typeof ComboBox; - TextField: typeof TextField; + TextField: typeof ComboBox.TextField; } = function Autocomplete({ - id, options, selected, textField, @@ -61,38 +61,89 @@ export const Autocomplete: React.FunctionComponent & { }: AutocompleteProps) { const i18n = useI18n(); - const spinnerMarkup = loading ? ( -
- -
+ const optionsMarkup = useMemo(() => { + const conditionalOptions = loading && !willLoadMoreResults ? [] : options; + const optionList = + conditionalOptions.length > 0 + ? conditionalOptions.map((option) => ( + + )) + : null; + + if (listTitle) { + return ( + {listTitle}} + > + {optionList} + + ); + } + + return optionList; + }, [ + listTitle, + loading, + options, + willLoadMoreResults, + allowMultiple, + selected, + ]); + + const loadingMarkup = loading ? ( + ) : null; - const conditionalOptions = loading && !willLoadMoreResults ? [] : options; - const conditionalAction = - actionBefore && actionBefore !== [] ? [actionBefore] : undefined; + const updateSelection = useCallback( + (newSelection: string) => { + if (allowMultiple) { + if (selected.includes(newSelection)) { + onSelect(selected.filter((option) => option !== newSelection)); + } else { + onSelect([...selected, newSelection]); + } + } else { + onSelect([newSelection]); + } + }, + [allowMultiple, onSelect, selected], + ); + + const actionMarkup = actionBefore && ; + + const emptyStateMarkup = emptyState && options.length < 1 && !loading && ( +
{emptyState}
+ ); return ( + onScrolledToBottom={onLoadMoreResults} + preferredPosition={preferredPosition} + > + {actionMarkup || optionsMarkup || loadingMarkup || emptyStateMarkup ? ( + + {actionMarkup} + {optionsMarkup && (!loading || willLoadMoreResults) + ? optionsMarkup + : null} + {loadingMarkup} + {emptyStateMarkup} + + ) : null} + ); }; -Autocomplete.ComboBox = ComboBox; -Autocomplete.TextField = TextField; +Autocomplete.TextField = ComboBox.TextField; diff --git a/src/components/Autocomplete/README.md b/src/components/Autocomplete/README.md index 700868152f0..84f064a471c 100644 --- a/src/components/Autocomplete/README.md +++ b/src/components/Autocomplete/README.md @@ -5,11 +5,13 @@ keywords: - autocomplete - searchable - typeahead + - combobox + - listbox --- # Autocomplete -The autocomplete component is an input field that provides selectable suggestions as a merchant types into it. It allows merchants to quickly search through and select from large collections of options. +The autocomplete component is an input field that provides selectable suggestions as a merchant types into it. It allows merchants to quickly search through and select from large collections of options. It's a convenience wrapper around the `ComboBox` and `ListBox` components with minor UI differences. --- @@ -77,7 +79,7 @@ function AutocompleteExample() { }); setSelectedOptions(selected); - setInputValue(selectedValue); + setInputValue(selectedValue[0]); }, [options], ); @@ -229,11 +231,11 @@ function AutocompleteExample() { setTimeout(() => { if (value === '') { setOptions(deselectedOptions); - setLoading(true); + setLoading(false); return; } const filterRegex = new RegExp(value, 'i'); - const resultOptions = options.filter((option) => + const resultOptions = deselectedOptions.filter((option) => option.label.match(filterRegex), ); setOptions(resultOptions); @@ -252,7 +254,7 @@ function AutocompleteExample() { return matchedOption && matchedOption.label; }); setSelectedOptions(selected); - setInputValue(selectedText); + setInputValue(selectedText[0]); }, [options], ); @@ -287,23 +289,39 @@ function AutocompleteExample() { function AutoCompleteLazyLoadExample() { const paginationInterval = 25; const deselectedOptions = Array.from(Array(100)).map((_, index) => ({ - value: `rustic ${index}`, - label: `Rustic ${index}`, + value: `rustic ${index + 1}`, + label: `Rustic ${index + 1}`, })); const [selectedOptions, setSelectedOptions] = useState([]); const [inputValue, setInputValue] = useState(''); const [options, setOptions] = useState(deselectedOptions); + const [isLoading, setIsLoading] = useState(false); + const [willLoadMoreResults, setWillLoadMoreResults] = useState(true); const [visibleOptionIndex, setVisibleOptionIndex] = useState( paginationInterval, ); const handleLoadMoreResults = useCallback(() => { - const nextVisibleOptionIndex = visibleOptionIndex + paginationInterval; - if (nextVisibleOptionIndex <= options.length - 1) { - setVisibleOptionIndex(nextVisibleOptionIndex); + if (willLoadMoreResults) { + setIsLoading(true); + + setTimeout(() => { + const remainingOptionCount = options.length - visibleOptionIndex; + const nextVisibleOptionIndex = + remainingOptionCount >= paginationInterval + ? visibleOptionIndex + paginationInterval + : visibleOptionIndex + remainingOptionCount; + + setIsLoading(false); + setVisibleOptionIndex(nextVisibleOptionIndex); + + if (remainingOptionCount <= paginationInterval) { + setWillLoadMoreResults(false); + } + }, 1000); } - }, [visibleOptionIndex, options.length]); + }, [willLoadMoreResults, visibleOptionIndex, options.length]); const removeTag = useCallback( (tag) => () => { @@ -324,7 +342,7 @@ function AutoCompleteLazyLoadExample() { } const filterRegex = new RegExp(value, 'i'); - const resultOptions = options.filter((option) => + const resultOptions = deselectedOptions.filter((option) => option.label.match(filterRegex), ); @@ -333,6 +351,7 @@ function AutoCompleteLazyLoadExample() { endIndex = 0; } setOptions(resultOptions); + setInputValue; }, [deselectedOptions, options], ); @@ -375,7 +394,9 @@ function AutoCompleteLazyLoadExample() { textField={textField} onSelect={setSelectedOptions} listTitle="Suggested Tags" + loading={isLoading} onLoadMoreResults={handleLoadMoreResults} + willLoadMoreResults={willLoadMoreResults} /> ); @@ -425,7 +446,7 @@ function AutocompleteExample() { return; } const filterRegex = new RegExp(value, 'i'); - const resultOptions = options.filter((option) => + const resultOptions = deselectedOptions.filter((option) => option.label.match(filterRegex), ); setOptions(resultOptions); @@ -444,7 +465,7 @@ function AutocompleteExample() { return matchedOption && matchedOption.label; }); setSelectedOptions(selected); - setInputValue(selectedText); + setInputValue(selectedText[0]); }, [options], ); @@ -483,12 +504,194 @@ function AutocompleteExample() { } ``` +### Autocomplete with action + +Use to indicate there are no search results. + +```jsx +function AutocompleteActionBeforeExample() { + const deselectedOptions = [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ]; + const [selectedOptions, setSelectedOptions] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + const [loading, setLoading] = useState(false); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (!loading) { + setLoading(true); + } + + setTimeout(() => { + if (value === '') { + setOptions(deselectedOptions); + setLoading(false); + return; + } + const filterRegex = new RegExp(value, 'i'); + const resultOptions = options.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + setLoading(false); + }, 300); + }, + [deselectedOptions, loading, options], + ); + + const updateSelection = useCallback( + (selected) => { + const selectedText = selected.map((selectedItem) => { + const matchedOption = options.find((option) => { + return option.value.match(selectedItem); + }); + return matchedOption && matchedOption.label; + }); + setSelectedOptions(selected); + setInputValue(selectedText[0]); + }, + [options], + ); + + const textField = ( + } + placeholder="Search" + /> + ); + + return ( +
+ +
+ ); +} +``` + +### Autocomplete with destructive action + +Use to indicate there are no search results. + +```jsx +function AutocompleteActionBeforeExample() { + const deselectedOptions = [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ]; + const [selectedOptions, setSelectedOptions] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + const [loading, setLoading] = useState(false); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (!loading) { + setLoading(true); + } + + setTimeout(() => { + if (value === '') { + setOptions(deselectedOptions); + setLoading(false); + return; + } + const filterRegex = new RegExp(value, 'i'); + const resultOptions = options.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + setLoading(false); + }, 300); + }, + [deselectedOptions, loading, options], + ); + + const updateSelection = useCallback( + (selected) => { + const selectedText = selected.map((selectedItem) => { + const matchedOption = options.find((option) => { + return option.value.match(selectedItem); + }); + return matchedOption && matchedOption.label; + }); + setSelectedOptions(selected); + setInputValue(selectedText[0]); + }, + [options], + ); + + const textField = ( + } + placeholder="Search" + /> + ); + + return ( +
+ +
+ ); +} +``` + --- ## Related components - For an input field without suggested options, [use the text field component](https://polaris.shopify.com/components/forms/text-field) - For a list of selectable options not linked to an input field, [use the option list component](https://polaris.shopify.com/components/lists-and-tables/option-list) +- For a text field that triggers a popover, [use the combo box component](https://polaris.shopify.com/components/forms/combobox) --- @@ -516,7 +719,7 @@ See Apple’s Human Interface Guidelines and API documentation about accessibili ### Structure -The autocomplete component is based on the [ARIA 1.1 combobox pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox). See the [text field component](https://polaris.shopify.com/components/forms/text-field) for information on implementing the autocomplete component with a text field. +The autocomplete component is based on the [ARIA 1.2 combobox pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox) and the [Aria 1.2 ListBox pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox). The autocomplete list displays below the text field or other control by default so it is easy for merchants to discover and use. However, you can change the position with the `preferredPosition` prop. diff --git a/src/components/Autocomplete/components/ComboBox/ComboBox.scss b/src/components/Autocomplete/components/ComboBox/ComboBox.scss deleted file mode 100644 index 36779ad0060..00000000000 --- a/src/components/Autocomplete/components/ComboBox/ComboBox.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import '../../../../styles/common'; - -.EmptyState { - padding: spacing(tight) spacing(); -} diff --git a/src/components/Autocomplete/components/ComboBox/ComboBox.tsx b/src/components/Autocomplete/components/ComboBox/ComboBox.tsx deleted file mode 100644 index 0de83583d7f..00000000000 --- a/src/components/Autocomplete/components/ComboBox/ComboBox.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import React, {useState, useEffect, useCallback} from 'react'; - -import {useUniqueId} from '../../../../utilities/unique-id'; -import {useToggle} from '../../../../utilities/use-toggle'; -import {OptionList, OptionDescriptor} from '../../../OptionList'; -import {ActionList} from '../../../ActionList'; -import {Popover, PopoverProps} from '../../../Popover'; -import {ActionListItemDescriptor, Key} from '../../../../types'; -import {KeypressListener} from '../../../KeypressListener'; -import {EventListener} from '../../../EventListener'; -import {useIsomorphicLayoutEffect} from '../../../../utilities/use-isomorphic-layout-effect'; - -import {ComboBoxContext} from './context'; -import styles from './ComboBox.scss'; - -export interface ComboBoxProps { - /** A unique identifier for the ComboBox */ - id?: string; - /** Collection of options to be listed */ - options: OptionDescriptor[]; - /** The selected options */ - selected: string[]; - /** The text field component attached to the list of options */ - textField: React.ReactElement; - /** The preferred direction to open the popover */ - preferredPosition?: PopoverProps['preferredPosition']; - /** Title of the list of options */ - listTitle?: string; - /** Allow more than one option to be selected */ - allowMultiple?: boolean; - /** Actions to be displayed before the list of options */ - actionsBefore?: ActionListItemDescriptor[]; - /** Actions to be displayed after the list of options */ - actionsAfter?: ActionListItemDescriptor[]; - /** Content to be displayed before the list of options */ - contentBefore?: React.ReactNode; - /** Content to be displayed after the list of options */ - contentAfter?: React.ReactNode; - /** Is rendered when there are no options */ - emptyState?: React.ReactNode; - /** Callback when the selection of options is changed */ - onSelect(selected: string[]): void; - /** Callback when the end of the list is reached */ - onEndReached?(): void; -} - -export function ComboBox({ - id: idProp, - options, - selected, - textField, - preferredPosition, - listTitle, - allowMultiple, - actionsBefore, - actionsAfter, - contentBefore, - contentAfter, - emptyState, - onSelect, - onEndReached, -}: ComboBoxProps) { - const [selectedIndex, setSelectedIndex] = useState(-1); - const [selectedOptions, setSelectedOptions] = useState(selected); - const [navigableOptions, setNavigableOptions] = useState< - (OptionDescriptor | ActionListItemDescriptor)[] - >([]); - const { - value: popoverActive, - setTrue: forcePopoverActiveTrue, - setFalse: forcePopoverActiveFalse, - } = useToggle(false); - - const id = useUniqueId('ComboBox', idProp); - - const getActionsWithIds = useCallback( - (actions: ActionListItemDescriptor[], before?: boolean) => { - if (before) { - return navigableOptions.slice(0, actions.length); - } - return navigableOptions.slice(-actions.length); - }, - [navigableOptions], - ); - - const visuallyUpdateSelectedOption = useCallback( - ( - newOption: OptionDescriptor | ActionListItemDescriptor, - oldOption: OptionDescriptor | ActionListItemDescriptor | undefined, - ) => { - if (oldOption) { - oldOption.active = false; - } - if (newOption) { - newOption.active = true; - } - }, - [], - ); - - const resetVisuallySelectedOptions = useCallback(() => { - setSelectedIndex(-1); - navigableOptions.forEach((option) => { - option.active = false; - }); - }, [navigableOptions]); - - const selectOptionAtIndex = useCallback( - (newOptionIndex: number) => { - if (navigableOptions.length === 0) { - return; - } - - const oldSelectedOption = navigableOptions[selectedIndex]; - const newSelectedOption = navigableOptions[newOptionIndex]; - - visuallyUpdateSelectedOption(newSelectedOption, oldSelectedOption); - - setSelectedIndex(newOptionIndex); - }, - [navigableOptions, selectedIndex, visuallyUpdateSelectedOption], - ); - - const selectNextOption = useCallback(() => { - if (navigableOptions.length === 0) { - return; - } - - let newIndex = selectedIndex; - - if (selectedIndex + 1 >= navigableOptions.length) { - newIndex = 0; - } else { - newIndex++; - } - - selectOptionAtIndex(newIndex); - }, [navigableOptions, selectOptionAtIndex, selectedIndex]); - - const selectPreviousOption = useCallback(() => { - if (navigableOptions.length === 0) { - return; - } - - let newIndex = selectedIndex; - - if (selectedIndex <= 0) { - newIndex = navigableOptions.length - 1; - } else { - newIndex--; - } - - selectOptionAtIndex(newIndex); - }, [navigableOptions, selectOptionAtIndex, selectedIndex]); - - const selectOptions = useCallback( - (selected: string[]) => { - selected && onSelect(selected); - if (!allowMultiple) { - resetVisuallySelectedOptions(); - forcePopoverActiveFalse(); - } - }, - [ - allowMultiple, - forcePopoverActiveFalse, - onSelect, - resetVisuallySelectedOptions, - ], - ); - - const handleSelection = useCallback( - (newSelected: string) => { - let newlySelectedOptions = selected; - if (selected.includes(newSelected)) { - newlySelectedOptions.splice( - newlySelectedOptions.indexOf(newSelected), - 1, - ); - } else if (allowMultiple) { - newlySelectedOptions.push(newSelected); - } else { - newlySelectedOptions = [newSelected]; - } - - selectOptions(newlySelectedOptions); - }, - [allowMultiple, selectOptions, selected], - ); - - const handleEnter = useCallback( - (event: KeyboardEvent) => { - if (event.keyCode !== Key.Enter) { - return; - } - - if (popoverActive && selectedIndex > -1) { - const selectedOption = navigableOptions[selectedIndex]; - if (isOption(selectedOption)) { - event.preventDefault(); - handleSelection(selectedOption.value); - } else { - selectedOption.onAction && selectedOption.onAction(); - } - } - }, - [handleSelection, navigableOptions, popoverActive, selectedIndex], - ); - - const handleBlur = useCallback(() => { - forcePopoverActiveFalse(); - resetVisuallySelectedOptions(); - }, [forcePopoverActiveFalse, resetVisuallySelectedOptions]); - - const activatePopover = useCallback(() => { - !popoverActive && forcePopoverActiveTrue(); - }, [forcePopoverActiveTrue, popoverActive]); - - const updateIndexOfSelectedOption = useCallback( - (newOptions: (OptionDescriptor | ActionListItemDescriptor)[]) => { - const selectedOption = navigableOptions[selectedIndex]; - if (selectedOption && newOptions.includes(selectedOption)) { - selectOptionAtIndex(newOptions.indexOf(selectedOption)); - } else if (selectedIndex > newOptions.length - 1) { - resetVisuallySelectedOptions(); - } else { - selectOptionAtIndex(selectedIndex); - } - }, - [ - navigableOptions, - resetVisuallySelectedOptions, - selectOptionAtIndex, - selectedIndex, - ], - ); - - useEffect(() => { - if (selectedOptions !== selected) { - setSelectedOptions(selected); - } - }, [selected, selectedOptions]); - - useIsomorphicLayoutEffect(() => { - let newNavigableOptions: ( - | OptionDescriptor - | ActionListItemDescriptor - )[] = []; - if (actionsBefore) { - newNavigableOptions = newNavigableOptions.concat(actionsBefore); - } - if (options) { - newNavigableOptions = newNavigableOptions.concat(options); - } - if (actionsAfter) { - newNavigableOptions = newNavigableOptions.concat(actionsAfter); - } - newNavigableOptions = assignOptionIds(newNavigableOptions, id); - setNavigableOptions(newNavigableOptions); - }, [actionsAfter, actionsBefore, id, options]); - - useEffect(() => { - updateIndexOfSelectedOption(navigableOptions); - }, [navigableOptions, updateIndexOfSelectedOption]); - - let actionsBeforeMarkup: JSX.Element | undefined; - if (actionsBefore && actionsBefore.length > 0) { - actionsBeforeMarkup = ( - - ); - } - - let actionsAfterMarkup: JSX.Element | undefined; - if (actionsAfter && actionsAfter.length > 0) { - actionsAfterMarkup = ( - - ); - } - - const optionsMarkup = options.length > 0 && ( - - ); - - const emptyStateMarkup = !actionsAfter && - !actionsBefore && - !contentAfter && - !contentBefore && - options.length === 0 && - emptyState &&
{emptyState}
; - - const selectedOptionId = - selectedIndex > -1 ? `${id}-${selectedIndex}` : undefined; - - const context = { - id, - selectedOptionId, - }; - - return ( - -
- - - - - - -
- {contentBefore} - {actionsBeforeMarkup} - {optionsMarkup} - {actionsAfterMarkup} - {contentAfter} - {emptyStateMarkup} -
-
-
-
-
- ); -} - -function assignOptionIds( - options: (OptionDescriptor | ActionListItemDescriptor)[], - id: string, -): OptionDescriptor[] | ActionListItemDescriptor[] { - return options.map((option, optionIndex) => ({ - ...option, - id: `${id}-${optionIndex}`, - })); -} - -function isOption( - navigableOption: OptionDescriptor | ActionListItemDescriptor, -): navigableOption is OptionDescriptor { - return 'value' in navigableOption && navigableOption.value !== undefined; -} - -function filterForOptions( - mixedArray: (ActionListItemDescriptor | OptionDescriptor)[], -): OptionDescriptor[] { - return mixedArray.filter(isOption); -} diff --git a/src/components/Autocomplete/components/ComboBox/context.tsx b/src/components/Autocomplete/components/ComboBox/context.tsx deleted file mode 100644 index f4a7c72edb3..00000000000 --- a/src/components/Autocomplete/components/ComboBox/context.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import {createContext} from 'react'; - -interface ComboBoxContextType { - comboBoxId?: string; - selectedOptionId?: string; -} - -export const ComboBoxContext = createContext({}); diff --git a/src/components/Autocomplete/components/ComboBox/index.ts b/src/components/Autocomplete/components/ComboBox/index.ts deleted file mode 100644 index d122f03ba63..00000000000 --- a/src/components/Autocomplete/components/ComboBox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ComboBox'; diff --git a/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx b/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx deleted file mode 100644 index 44e5be9514b..00000000000 --- a/src/components/Autocomplete/components/ComboBox/tests/ComboBox.test.tsx +++ /dev/null @@ -1,639 +0,0 @@ -import React from 'react'; -import {OptionList, ActionList, Popover} from 'components'; -import {mountWithApp} from 'test-utilities'; -// eslint-disable-next-line no-restricted-imports -import {mountWithAppProvider, act} from 'test-utilities/legacy'; - -import {TextField} from '../../TextField'; -import {Key} from '../../../../../types'; -import {ComboBox} from '../ComboBox'; - -describe('', () => { - const options = [ - {value: 'cheese_pizza', label: 'Cheese Pizza'}, - {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, - {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, - ]; - - const action = [ - { - content: 'Add tag', - onAction: noop, - }, - ]; - - describe('options', () => { - it('passes options to OptionList', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('click'); - - const optionListOptions = comboBox.find(OptionList).prop('options') || [ - { - value: '', - label: '', - }, - ]; - - expect(optionListOptions[0].value).toBe('cheese_pizza'); - expect(optionListOptions[0].label).toBe('Cheese Pizza'); - expect(optionListOptions[1].value).toBe('macaroni_pizza'); - expect(optionListOptions[1].label).toBe('Macaroni Pizza'); - }); - - it.each([ - [options, 0], - [[], -1], - ])('sets tabIndex depending of number of options', (options, tabIndex) => { - const comboBox = mountWithApp( - , - ); - - expect(comboBox.find('div')).toHaveReactProps({ - tabIndex, - }); - }); - }); - - describe('contentBefore and contentAfter', () => { - it('renders content passed into contentBefore', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('#CustomNode')).toHaveLength(1); - }); - - it('renders content passed into contentAfter', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('#CustomNode')).toHaveLength(1); - }); - }); - - describe('actionsBefore and actionsAfter', () => { - const comboBox = mountWithAppProvider( - , - ); - - it('passes actionsBefore to the options in the first ActionList', () => { - comboBox.simulate('click'); - - const actionListItems = comboBox - .find(ActionList) - .first() - .prop('items') || [ - { - image: '', - role: '', - }, - ]; - - expect(actionListItems[0].image).toBe('../image/path'); - expect(actionListItems[0].role).toBe('option'); - }); - - it('passes actionsAfter to the options in the second ActionList', () => { - comboBox.simulate('click'); - - const actionListItems = comboBox - .find(ActionList) - .last() - .prop('items') || [ - { - image: '', - role: '', - }, - ]; - - expect(actionListItems[0].image).toBe('../image/path'); - expect(actionListItems[0].role).toBe('option'); - }); - }); - - describe('ids', () => { - it('passes an id to the options in OptionList', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('button').at(0).prop('id')).toBe('TestId-0'); - }); - - it('passes an id to the actions in ActionList', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('button').at(0).prop('id')).toBe('TestId-0'); - }); - }); - - describe('actions', () => { - it('renders an action in actionsBefore', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('click'); - - expect(comboBox.find('button').at(0).text()).toBe('Add tag'); - }); - - it('renders an action in actionsAfter', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('button').at(3).text()).toBe('Add tag'); - }); - }); - - describe('select', () => { - it('passes the selected options to OptionList', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('click'); - expect(comboBox.find(OptionList).prop('selected')).toStrictEqual([ - 'cheese_pizza', - ]); - }); - }); - - describe('listTitle', () => { - it('passes the listTitle as title to OptionList', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('click'); - expect(comboBox.find(OptionList).prop('title')).toBe('List title'); - }); - }); - - describe('', () => { - it('renders TextField by default', () => { - const comboBox = mountWithAppProvider( - , - ); - expect(comboBox.find(TextField)).toHaveLength(1); - }); - - it('renders a custom given input', () => { - const comboBox = mountWithAppProvider( - } - onSelect={noop} - />, - ); - expect(comboBox.find('input')).toHaveLength(1); - expect(comboBox.find(TextField)).toHaveLength(0); - }); - - it('is passed to Popover as the activator', () => { - const comboBox = mountWithAppProvider( - , - ); - - expect(comboBox.find(Popover).find(TextField)).toHaveLength(1); - }); - }); - - describe('', () => { - const comboBox = mountWithAppProvider( - , - ); - - it('does not set Popover to active before being clicked', () => { - expect(comboBox.find(Popover).prop('active')).toBe(false); - }); - - it('sets Popover to active when clicked', () => { - comboBox.simulate('click'); - expect(comboBox.find(Popover).prop('active')).toBe(true); - }); - - it('sets Popover to active on keyDown', () => { - comboBox.simulate('keydown'); - expect(comboBox.find(Popover).prop('active')).toBe(true); - }); - - it('sets Popover to fullWidth', () => { - expect(comboBox.find(Popover).prop('fullWidth')).toBe(true); - }); - - it('prevents autofocus on Popover', () => { - expect(comboBox.find(Popover).prop('autofocusTarget')).toBe('none'); - }); - - it('passes the preferredPosition to Popover', () => { - expect(comboBox.find(Popover).prop('preferredPosition')).toBe('above'); - }); - }); - - describe('allowMultiple', () => { - it('renders a button if the prop is false', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('button')).toHaveLength(options.length); - }); - - it('renders a checkbox if the prop is set to true', () => { - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - expect(comboBox.find('input[type="checkbox"]')).toHaveLength( - options.length, - ); - }); - }); - - describe('onSelect', () => { - it('gets called when an item is clicked', () => { - const spy = jest.fn(); - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - comboBox.find('button').at(0).simulate('click'); - expect(spy).toHaveBeenCalledTimes(1); - }); - - it('gets called when a checkbox is changed', () => { - const spy = jest.fn(); - const comboBox = mountWithAppProvider( - , - ); - comboBox.simulate('click'); - comboBox - .find('input[type="checkbox"]') - .at(0) - .simulate('change', {target: {checked: true}}); - expect(spy).toHaveBeenCalledTimes(1); - }); - }); - - describe('onEndReached', () => { - it('gets called when the end of the option list is reached', () => { - const spy = jest.fn(); - const comboBox = mountWithApp( - , - ); - - // Focus the combobox so that the popover pane is rendered - comboBox.find('div')!.trigger('onFocus'); - - comboBox.find(Popover.Pane)!.trigger('onScrolledToBottom'); - expect(spy).toHaveBeenCalledTimes(1); - }); - }); - - describe('keypress events', () => { - // Jest 25 / JSDOM 16 causes this test case to go into an infinite loop and - // never recover. Skip for now till we can find a fix - // eslint-disable-next-line jest/no-disabled-tests - it.skip('handles key events when there are no previous options', () => { - const spy = jest.fn(); - const options: {value: string; label: string}[] = []; - const comboBox = mountWithAppProvider( - , - ); - comboBox.find(TextField).simulate('click'); - act(() => { - dispatchKeyup(Key.DownArrow); - }); - act(() => { - dispatchKeydown(Key.Enter); - }); - expect(spy).not.toHaveBeenCalled(); - - comboBox.setProps({ - options: [ - {value: 'cheese_pizza', label: 'Cheese Pizza'}, - {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, - {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, - ], - }); - comboBox.update(); - comboBox.find(TextField).simulate('click'); - act(() => { - dispatchKeyup(Key.DownArrow); - }); - act(() => { - dispatchKeydown(Key.Enter); - }); - expect(spy).toHaveBeenCalledWith(['cheese_pizza']); - }); - - // Jest 25 / JSDOM 16 causes this test case to go into an infinite loop and - // never recover. Skip for now till we can find a fix - // eslint-disable-next-line jest/no-disabled-tests - it.skip('adds to selected options when the down arrow and enter keys are pressed', () => { - const spy = jest.fn(); - const comboBox = mountWithAppProvider( - , - ); - comboBox.find(TextField).simulate('click'); - act(() => { - dispatchKeyup(Key.DownArrow); - }); - act(() => { - dispatchKeydown(Key.Enter); - }); - expect(spy).toHaveBeenCalledWith(['cheese_pizza']); - }); - - // Jest 25 / JSDOM 16 causes this test case to go into an infinite loop and - // never recover. Skip for now till we can find a fix - // eslint-disable-next-line jest/no-disabled-tests - it.skip('does not add to selected options when the down arrow and key other than enter is pressed', () => { - const spy = jest.fn(); - const comboBox = mountWithAppProvider( - , - ); - comboBox.find(TextField).simulate('click'); - act(() => { - dispatchKeyup(Key.DownArrow); - }); - act(() => { - dispatchKeydown(Key.RightArrow); - }); - expect(spy).not.toHaveBeenCalled(); - }); - - it('activates the popover when the combobox is focused', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.simulate('focus'); - expect(comboBox.find(Popover).prop('active')).toBe(true); - }); - - it('deactivates the popover when the escape key is pressed', () => { - const comboBox = mountWithAppProvider( - , - ); - - comboBox.find(TextField).simulate('click'); - expect(comboBox.find(Popover).prop('active')).toBe(true); - - act(() => { - dispatchKeyup(Key.Escape); - }); - - comboBox.update(); - expect(comboBox.find(Popover).prop('active')).toBe(false); - }); - }); - - describe('empty state', () => { - const EmptyState = () =>
No results
; - - it('renders an empty state when no options are passed in', () => { - const comboBox = mountWithAppProvider( - } - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(1); - }); - - it('does not render empty state if actionsBefore exist', () => { - const comboBox = mountWithAppProvider( - } - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(0); - }); - - it('does not render empty state if actionsAfter exist', () => { - const comboBox = mountWithAppProvider( - } - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(0); - }); - - it('does not render empty state if contentAfter exist', () => { - const comboBox = mountWithAppProvider( - Content after
} - emptyState={} - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(0); - }); - - it('does not render empty state if contentBefore exist', () => { - const comboBox = mountWithAppProvider( - Content before} - emptyState={} - />, - ); - - comboBox.simulate('click'); - expect(comboBox.find(EmptyState)).toHaveLength(0); - }); - }); -}); - -function noop() {} - -function renderTextField() { - return ; -} - -function renderNodeWithId() { - return
; -} - -function dispatchKeyup(key: Key) { - const event: KeyboardEventInit & {keyCode: Key} = {keyCode: key}; - document.dispatchEvent(new KeyboardEvent('keyup', event)); -} - -function dispatchKeydown(key: Key) { - const event: KeyboardEventInit & {keyCode: Key} = {keyCode: key}; - window.dispatchEvent(new KeyboardEvent('keydown', event)); -} diff --git a/src/components/Autocomplete/components/MappedAction/MappedAction.scss b/src/components/Autocomplete/components/MappedAction/MappedAction.scss new file mode 100644 index 00000000000..3094c223bd4 --- /dev/null +++ b/src/components/Autocomplete/components/MappedAction/MappedAction.scss @@ -0,0 +1,112 @@ +@import '../../../../styles/common'; + +$image-size: rem(20px); +$item-min-height: rem(40px); +$item-vertical-padding: ($item-min-height - line-height(body)) / 2; + +.ActionContainer { + margin-bottom: spacing(base-tight); +} + +[data-focused] { + .Action { + @include recolor-icon(var(--p-interactive)); + + &.destructive { + background-color: var(--p-surface-critical-subdued-pressed); + } + } +} + +.Action { + @include focus-ring; + display: block; + width: 100%; + min-height: $item-min-height; + text-align: left; + cursor: pointer; + padding: $item-vertical-padding spacing(tight); + border-radius: var(--p-border-radius-base); + border-top: 1px solid var(--p-surface); // 1px gap between elements + + &:hover { + background-color: var(--p-surface-hovered); + text-decoration: none; + + @media (-ms-high-contrast: active) { + outline: 1px solid ms-high-contrast-color('text'); + } + } + + &.selected { + @include recolor-icon(var(--p-interactive)); + background-color: var(--p-surface-selected); + } + + &:active { + @include recolor-icon(var(--p-interactive)); + background-color: var(--p-surface-pressed); + } + + &:focus:not(:active) { + @include focus-ring($style: 'focused'); + } + + &.destructive { + @include recolor-icon(var(--p-icon-critical)); + color: var(--p-interactive-critical); + + &:hover { + background-color: var(--p-surface-critical-subdued-hovered); + } + + // stylelint-disable-next-line selector-max-class + &:active, + &.selected { + background-color: var(--p-surface-critical-subdued-pressed); + } + } + + &.disabled { + background-image: none; + color: var(--p-text-disabled); + + // stylelint-disable-next-line selector-max-class + .Prefix, + .Suffix { + @include recolor-icon(var(--p-icon-disabled)); + } + } +} + +.Content { + display: flex; + align-items: center; +} + +.Prefix { + @include recolor-icon(var(--p-icon)); + display: flex; + flex: 0 0 auto; + justify-content: center; + align-items: center; + height: $image-size; + width: $image-size; + border-radius: border-radius(); + + // We need the negative margin to ensure that the image does not set + // the minimum height of the action item. + margin: (-0.5 * $image-size) spacing() (-0.5 * $image-size) 0; + background-size: cover; + background-position: center center; +} + +.Suffix { + @include recolor-icon(var(--p-icon)); + margin-left: spacing(); +} + +.Text { + @include layout-flex-fix; + flex: 1 1 auto; +} diff --git a/src/components/Autocomplete/components/MappedAction/MappedAction.tsx b/src/components/Autocomplete/components/MappedAction/MappedAction.tsx new file mode 100644 index 00000000000..86d42218f68 --- /dev/null +++ b/src/components/Autocomplete/components/MappedAction/MappedAction.tsx @@ -0,0 +1,122 @@ +import React, {useMemo} from 'react'; + +import type {ActionListItemDescriptor} from '../../../../types'; +import {Badge} from '../../../Badge'; +import {classNames} from '../../../../utilities/css'; +import {MappedActionContext} from '../../../../utilities/autocomplete'; +import {ListBox} from '../../../ListBox'; +import {Icon} from '../../../Icon'; +import {TextStyle} from '../../../TextStyle'; +import {useI18n} from '../../../../utilities/i18n'; + +import styles from './MappedAction.scss'; + +interface MappedAction extends ActionListItemDescriptor {} + +export function MappedAction({ + active, + content, + disabled, + icon, + image, + prefix, + suffix, + ellipsis, + role, + url, + external, + onAction, + destructive, + badge, + helpText, +}: MappedAction) { + const i18n = useI18n(); + + let prefixMarkup: React.ReactNode | null = null; + + if (prefix) { + prefixMarkup =
{prefix}
; + } else if (icon) { + prefixMarkup = ( +
+ +
+ ); + } else if (image) { + prefixMarkup = ( +
+ ); + } + + const badgeMarkup = badge && ( + + {badge.content} + + ); + + const suffixMarkup = suffix && ( + {suffix} + ); + + const contentText = + ellipsis && content + ? i18n.translate('Polaris.Autocomplete.ellipsis', {content}) + : content; + + const contentMarkup = ( +
+ {helpText ? ( + <> +
{contentText}
+ {helpText} + + ) : ( + contentText + )} +
+ ); + + const context = useMemo( + () => ({ + role, + url, + external, + onAction, + destructive, + isAction: true, + }), + [role, url, external, onAction, destructive], + ); + + const actionClassNames = classNames( + styles.Action, + disabled && styles.disabled, + destructive && styles.destructive, + active && styles.selected, + ); + + return ( + +
+ +
+
+ {prefixMarkup} + {contentMarkup} + {badgeMarkup} + {suffixMarkup} +
+
+
+
+
+ ); +} diff --git a/src/components/Autocomplete/components/MappedAction/index.ts b/src/components/Autocomplete/components/MappedAction/index.ts new file mode 100644 index 00000000000..a4230c37cdf --- /dev/null +++ b/src/components/Autocomplete/components/MappedAction/index.ts @@ -0,0 +1 @@ +export * from './MappedAction'; diff --git a/src/components/Autocomplete/components/MappedAction/tests/MappedAction.test.tsx b/src/components/Autocomplete/components/MappedAction/tests/MappedAction.test.tsx new file mode 100644 index 00000000000..cbd2256e83e --- /dev/null +++ b/src/components/Autocomplete/components/MappedAction/tests/MappedAction.test.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import {mountWithListBoxProvider} from 'test-utilities/list-box'; + +import {ListBox} from '../../../../ListBox'; +import {MappedAction} from '../MappedAction'; +import {MappedActionContext} from '../../../../../utilities/autocomplete'; +import {Badge} from '../../../../Badge'; +import {Icon} from '../../../../Icon'; + +describe('MappedAction', () => { + it('renders badge when provided', () => { + const badge = { + status: 'new' as const, + content: 'new', + }; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(Badge, { + status: badge.status, + children: badge.content, + }); + }); + + it('renders suffix when provided', () => { + const mappedAction = mountWithListBoxProvider( + } />, + ); + + expect(mappedAction).toContainReactComponent(MockComponent); + }); + + it('renders helpText when provided', () => { + const helpText = 'help text'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactText(helpText); + }); + + it('renders ellipsis when true', () => { + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactText('…'); + }); + + it('renders MappedActionContext provider with values', () => { + const props = { + role: 'role', + url: 'url', + external: false, + onAction: () => {}, + destructive: false, + }; + const mappedAction = mountWithListBoxProvider(); + + expect(mappedAction).toContainReactComponent(MappedActionContext.Provider, { + value: { + ...props, + isAction: true, + }, + }); + }); + + describe('ListBox.Action', () => { + it('renders', () => { + const mappedAction = mountWithListBoxProvider(); + + expect(mappedAction).toContainReactComponent(ListBox.Action); + }); + + it('passes active', () => { + const mappedAction = mountWithListBoxProvider(); + + expect(mappedAction).toContainReactComponent(ListBox.Action, { + selected: true, + }); + }); + + it('passes disabled', () => { + const disabled = true; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(ListBox.Action, { + disabled, + }); + }); + + it('passes value', () => { + const value = 'value'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(ListBox.Action, { + value, + }); + }); + + it('defaults value to an empty string', () => { + const value = ''; + const mappedAction = mountWithListBoxProvider(); + + expect(mappedAction).toContainReactComponent(ListBox.Action, { + value, + }); + }); + }); + + describe('prefix markup', () => { + it('renders images', () => { + const image = 'image'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent('div', { + role: 'presentation', + }); + }); + + it('renders icon', () => { + const source = 'icon'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(Icon, {source}); + }); + + it('renders prefix', () => { + const mappedAction = mountWithListBoxProvider( + } />, + ); + + expect(mappedAction).toContainReactComponent(MockComponent); + }); + + it('renders icon instead of image', () => { + const source = 'icon'; + const image = 'image'; + const mappedAction = mountWithListBoxProvider( + , + ); + + expect(mappedAction).toContainReactComponent(Icon, {source}); + expect(mappedAction).not.toContainReactComponent('div', { + role: 'presentation', + }); + }); + + it('renders prefix instead of image', () => { + const image = 'image'; + const mappedAction = mountWithListBoxProvider( + } image={image} />, + ); + + expect(mappedAction).toContainReactComponent(MockComponent); + expect(mappedAction).not.toContainReactComponent('div', { + role: 'presentation', + }); + }); + + it('renders prefix instead of icon', () => { + const source = 'icon'; + const mappedAction = mountWithListBoxProvider( + } icon={source} />, + ); + + expect(mappedAction).toContainReactComponent(MockComponent); + expect(mappedAction).not.toContainReactComponent(Icon, {source}); + }); + }); +}); + +function MockComponent() { + return null; +} diff --git a/src/components/Autocomplete/components/MappedOption/MappedOption.scss b/src/components/Autocomplete/components/MappedOption/MappedOption.scss new file mode 100644 index 00000000000..6cbdbc342e0 --- /dev/null +++ b/src/components/Autocomplete/components/MappedOption/MappedOption.scss @@ -0,0 +1,22 @@ +@import '../../../../styles/common'; + +.Content { + display: flex; + flex: 1; +} + +.Media { + @include recolor-icon(var(--p-icon, color('ink', 'light')), color('white')); + padding: 0 spacing(tight); +} + +.singleSelectionMedia { + padding: 0 spacing(tight) 0 0; +} + +.disabledMedia { + @include recolor-icon( + var(--p-icon-disabled, color('ink', 'lightest')), + color('white') + ); +} diff --git a/src/components/Autocomplete/components/MappedOption/MappedOption.tsx b/src/components/Autocomplete/components/MappedOption/MappedOption.tsx new file mode 100644 index 00000000000..dec94df5197 --- /dev/null +++ b/src/components/Autocomplete/components/MappedOption/MappedOption.tsx @@ -0,0 +1,51 @@ +import React, {memo} from 'react'; + +import {ListBox} from '../../../ListBox'; +import type {OptionDescriptor} from '../../../OptionList'; +import type {ArrayElement} from '../../../../types'; +import {classNames} from '../../../../utilities/css'; + +import styles from './MappedOption.scss'; + +type MappedOption = ArrayElement & { + selected: boolean; + singleSelection: boolean; +}; + +export const MappedOption = memo(function MappedOption({ + label, + value, + disabled, + media, + selected, + singleSelection, +}: MappedOption) { + const mediaClassNames = classNames( + styles.Media, + disabled && styles.disabledMedia, + singleSelection && styles.singleSelectionMedia, + ); + + const mediaMarkup = media ? ( +
{media}
+ ) : null; + + const accessibilityLabel = typeof label === 'string' ? label : undefined; + + return ( + + +
+ {mediaMarkup} + {label} +
+
+
+ ); +}); diff --git a/src/components/Autocomplete/components/MappedOption/index.ts b/src/components/Autocomplete/components/MappedOption/index.ts new file mode 100644 index 00000000000..58a02ae72a3 --- /dev/null +++ b/src/components/Autocomplete/components/MappedOption/index.ts @@ -0,0 +1 @@ +export * from './MappedOption'; diff --git a/src/components/Autocomplete/components/MappedOption/tests/MappedOption.test.tsx b/src/components/Autocomplete/components/MappedOption/tests/MappedOption.test.tsx new file mode 100644 index 00000000000..1d5ec593c8d --- /dev/null +++ b/src/components/Autocomplete/components/MappedOption/tests/MappedOption.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import {mountWithListBoxProvider} from 'test-utilities/list-box'; + +import {ListBox} from '../../../../ListBox'; +import {MappedOption} from '../MappedOption'; + +describe('MappedOption', () => { + const defaultProps = { + value: 'value', + label: 'label', + selected: false, + singleSelection: false, + }; + + it('renders label markup', () => { + const label = 'Test label'; + const mappedOption = mountWithListBoxProvider( + , + ); + + expect(mappedOption).toContainReactText(label); + }); + + describe('accessibility', () => { + it('does not apply an accessibility label when label is not a string', () => { + const label =
test label
; + const mappedOption = mountWithListBoxProvider( + , + ); + + expect(mappedOption).toContainReactComponent(ListBox.Option, { + accessibilityLabel: undefined, + }); + }); + }); + + describe('ListBox', () => { + it('renders ListBox.Option', () => { + const mappedOption = mountWithListBoxProvider( + , + ); + + expect(mappedOption).toContainReactComponent(ListBox.Option); + }); + + it('renders ListBox.TextOption', () => { + const mappedOption = mountWithListBoxProvider( + , + ); + + expect(mappedOption).toContainReactComponent(ListBox.TextOption); + }); + }); + + describe('media', () => { + it('renders markup when provided', () => { + const mappedOption = mountWithListBoxProvider( + } />, + ); + + expect(mappedOption).toContainReactComponent(MockComponent); + }); + + it('renders with disabled styles when disabled', () => { + const mappedOption = mountWithListBoxProvider( + } />, + ); + + expect(mappedOption).toContainReactComponent('div', { + className: 'Media disabledMedia', + }); + }); + + it('renders with single selection styles when singleSelection is true', () => { + const mappedOption = mountWithListBoxProvider( + } + />, + ); + + expect(mappedOption).toContainReactComponent('div', { + className: 'Media singleSelectionMedia', + }); + }); + }); +}); + +function MockComponent() { + return null; +} diff --git a/src/components/Autocomplete/components/TextField/TextField.tsx b/src/components/Autocomplete/components/TextField/TextField.tsx deleted file mode 100644 index 880fe3513da..00000000000 --- a/src/components/Autocomplete/components/TextField/TextField.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -// eslint-disable-next-line @shopify/strict-component-boundaries -import {ComboBoxContext} from '../ComboBox/context'; -import {TextField as BaseTextField, TextFieldProps} from '../../../TextField'; - -export function TextField(props: TextFieldProps) { - return ( - - {({selectedOptionId, comboBoxId}) => ( - - )} - - ); -} diff --git a/src/components/Autocomplete/components/TextField/index.ts b/src/components/Autocomplete/components/TextField/index.ts deleted file mode 100644 index 665fa3cb54f..00000000000 --- a/src/components/Autocomplete/components/TextField/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './TextField'; diff --git a/src/components/Autocomplete/components/index.ts b/src/components/Autocomplete/components/index.ts index c8e78f10ae4..42e65fc4cbf 100644 --- a/src/components/Autocomplete/components/index.ts +++ b/src/components/Autocomplete/components/index.ts @@ -1,3 +1,2 @@ -export * from './ComboBox'; - -export * from './TextField'; +export * from './MappedOption'; +export * from './MappedAction'; diff --git a/src/components/Autocomplete/tests/Autocomplete.test.tsx b/src/components/Autocomplete/tests/Autocomplete.test.tsx index 95f141d0988..51d2034d634 100644 --- a/src/components/Autocomplete/tests/Autocomplete.test.tsx +++ b/src/components/Autocomplete/tests/Autocomplete.test.tsx @@ -1,22 +1,33 @@ import React from 'react'; -import {CirclePlusMinor} from '@shopify/polaris-icons'; -// eslint-disable-next-line no-restricted-imports -import {mountWithAppProvider, trigger} from 'test-utilities/legacy'; -import {Spinner} from 'components'; +import {mountWithApp, ReactTestingElement, CustomRoot} from 'test-utilities'; +import {KeypressListener} from 'components'; +import {TextField} from '../../TextField'; import {Key} from '../../../types'; -import {ComboBox} from '../components'; +import {MappedOption, MappedAction} from '../components'; +import {ComboBoxTextFieldContext} from '../../../utilities/combo-box'; import {Autocomplete} from '../Autocomplete'; +import {ComboBox} from '../../ComboBox'; +import type {ComboBoxProps} from '../../ComboBox'; +import {ListBox} from '../../ListBox'; describe('', () => { const options = [ - {value: 'cheese_pizza', label: 'Cheese Pizza'}, - {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, - {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, + {value: 'cheese_pizza', label: 'Cheese Pizza', id: '1'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza', id: '2'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza', id: '3'}, ]; + const defaultProps = { + options, + selected: [], + textField: ( + + ), + onSelect: noop, + }; it('mounts', () => { - const autocomplete = mountWithAppProvider( + const autocomplete = mountWithApp( ', () => { onSelect={noop} />, ); - expect(autocomplete.find(Autocomplete).exists()).toBe(true); + expect(autocomplete).toContainReactComponent(ComboBox); }); it('displays a spinner when loading is true', () => { - const autocomplete = mountWithAppProvider( + const autocomplete = mountWithApp( ', () => { loading />, ); - autocomplete.simulate('click'); - expect(autocomplete.find(Spinner).exists()).toBe(true); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(ListBox.Loading); }); describe('', () => { - it('passes props to ComboBox', () => { - const actionBefore = { - content: "Add 'f'", - icon: CirclePlusMinor, - id: 'ComboBox3-0', - }; + describe('props', () => { + describe('id', () => { + // id is a noop in the new implementation - test is to ensure we keep the id prop + it('does nothing', () => { + const id = 'unique_id_Jf939sjf8js8NNsJ8'; + const autocomplete = mountWithApp( + , + ); - const EmptyState = () => No results; + expect(autocomplete).not.toContainReactHtml(id); + }); + }); - const autocomplete = mountWithAppProvider( - } - />, - ); + describe('options', () => { + it('renders a ListBox.Option for each option', () => { + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, + {value: 'other_pizza', label: 'Other Pizza'}, + ]; + const autocomplete = mountWithApp( + , + ); - expect(autocomplete.find(ComboBox).prop('id')).toBe('Autocomplete-ID'); - expect(autocomplete.find(ComboBox).prop('options')).toBe(options); - expect(autocomplete.find(ComboBox).prop('selected')).toStrictEqual([ - 'cheese_pizza', - ]); - expect(autocomplete.find(ComboBox).prop('textField')).toStrictEqual( - renderTextField(), - ); - expect(autocomplete.find(ComboBox).prop('preferredPosition')).toBe( - 'mostSpace', - ); - expect(autocomplete.find(ComboBox).prop('listTitle')).toBe('List title'); - expect(autocomplete.find(ComboBox).prop('allowMultiple')).toBe(true); - expect(autocomplete.find(ComboBox).prop('actionsBefore')).toStrictEqual([ - actionBefore, - ]); - expect(autocomplete.find(ComboBox).prop('onSelect')).toBe(handleOnSelect); - expect(autocomplete.find(ComboBox).prop('emptyState')).toStrictEqual( - , - ); + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponentTimes( + ListBox.Option, + options.length, + ); + }); + + it('passes selected to ListBox.Option', () => { + const selected = 'cheese_pizza'; + const options = [ + {value: selected, label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, + {value: 'other_pizza', label: 'Other Pizza'}, + ]; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(MappedOption, { + ...options[0], + selected: true, + }); + }); + }); + + describe('selected', () => { + it('renders selected values on options', () => { + const selectedOption = { + value: 'cheese_pizza', + label: 'Cheese Pizza', + id: '1', + }; + const options = [ + selectedOption, + {value: 'macaroni_pizza', label: 'Macaroni Pizza', id: '2'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza', id: '3'}, + {value: 'other_pizza', label: 'Other Pizza', id: '4'}, + ]; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(MappedOption, { + ...selectedOption, + selected: true, + }); + }); + }); + + describe('textField', () => { + it('is passed to ComboBox', () => { + const textField = ( + + ); + const autocomplete = mountWithApp( + , + ); + + expect(autocomplete).toContainReactComponent(ComboBox, { + activator: textField, + }); + }); + }); + + describe('preferredPosition', () => { + it('is passed to ComboBox', () => { + const preferredPosition = 'above'; + const autocomplete = mountWithApp( + , + ); + + expect(autocomplete).toContainReactComponent(ComboBox, { + preferredPosition, + }); + }); + }); + + describe('listTitle', () => { + it('renders a ListBoxSection with a ListBoxHeader', () => { + const listTitle = 'title'; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(ListBox.Section, { + divider: false, + }); + }); + }); + + describe('allowMultiple', () => { + it('is passed to ComboBox', () => { + const allowMultiple = true; + const autocomplete = mountWithApp( + , + ); + + expect(autocomplete).toContainReactComponent(ComboBox, { + allowMultiple, + }); + }); + }); + + describe('actionBefore', () => { + it('renders MappedAction', () => { + const actionBefore = { + accessibilityLabel: 'label', + helpText: 'help text', + image: '', + prefix: null, + suffix: null, + ellipsis: false, + active: false, + role: 'option', + icon: 'icon', + disabled: false, + destructive: true, + badge: { + status: 'new' as const, + content: 'new', + }, + }; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(MappedAction); + }); + }); + + describe('loading', () => { + it('renders ListBox.Loading', () => { + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(ListBox.Loading); + }); + }); + + describe('willLoadMoreResults', () => { + it('renders options while loading', () => { + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + {value: 'pepperoni_pizza', label: 'Pepperoni Pizza'}, + {value: 'other_pizza', label: 'Other Pizza'}, + ]; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponentTimes( + ListBox.Option, + options.length, + ); + }); + }); + + describe('emptyState', () => { + function EmptyState() { + return null; + } + + it('does not render if an action exists', () => { + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).not.toContainReactComponent(EmptyState); + }); + + it('does not render when options exists', () => { + const emptyState = ; + const options = [{value: 'cheese_pizza', label: 'Cheese Pizza'}]; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).not.toContainReactComponent(EmptyState); + }); + + it('does not render when loading is true', () => { + const emptyState = ; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).not.toContainReactComponent(EmptyState); + }); + + it("renders while loading is false and options don't exists", () => { + const emptyState = ; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + + expect(autocomplete).toContainReactComponent(EmptyState); + }); + }); + + describe('onSelect', () => { + it('is called when the newly selected value', () => { + const onSelectSpy = jest.fn(); + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + ]; + const value = options[0].value; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + triggerOnSelect(autocomplete, value); + + expect(onSelectSpy).toHaveBeenLastCalledWith([value]); + }); + + it('is not called with the deselected value when allowMultiple is true', () => { + const onSelectSpy = jest.fn(); + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + ]; + const value = options[0].value; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + triggerOnSelect(autocomplete, value); + + expect(onSelectSpy).toHaveBeenLastCalledWith([]); + }); + + it('is called with multiple values when allowMultiple is true', () => { + const onSelectSpy = jest.fn(); + const options = [ + {value: 'cheese_pizza', label: 'Cheese Pizza'}, + {value: 'macaroni_pizza', label: 'Macaroni Pizza'}, + ]; + const valueOne = options[0].value; + const valueTwo = options[1].value; + const autocomplete = mountWithApp( + , + ); + + triggerFocus(autocomplete.find(ComboBox)); + triggerOnSelect(autocomplete, valueTwo); + + expect(onSelectSpy).toHaveBeenLastCalledWith([valueOne, valueTwo]); + }); + }); + + describe('onLoadMoreResults', () => { + it('is passed to ComboBox', () => { + const onLoadMoreResults = jest.fn(); + const autocomplete = mountWithApp( + , + ); + + expect(autocomplete).toContainReactComponent(ComboBox, { + onScrolledToBottom: onLoadMoreResults, + }); + }); + }); }); - it('`Enter` keypress in does not trigger `onSubmit` when wrapped in a
', () => { - const spy = jest.fn(); + it('`Enter` keypress in prevents default to stop `onSubmit` from being called when wrapped in a ', () => { + const preventDefaultSpy = jest.fn(); - const autocomplete = mountWithAppProvider( - + const autocomplete = mountWithApp( + ', () => { , ); - autocomplete.find(Autocomplete).simulate('click'); + triggerFocus(autocomplete.find(ComboBox)); + autocomplete + .find(ComboBox.TextField) + ?.find(TextField) + ?.trigger('onFocus'); autocomplete - .find(Autocomplete) - .simulate('keyup', {keyCode: Key.DownArrow}); - autocomplete.find(Autocomplete).simulate('keyDown', {keyCode: Key.Enter}); - expect(spy).not.toHaveBeenCalled(); + .find(KeypressListener, {keyCode: Key.Enter})! + .trigger('handler', { + preventDefault: preventDefaultSpy, + stopPropagation: noop, + }); + + expect(preventDefaultSpy).toHaveBeenCalled(); }); }); describe('loading', () => { - it('passes an empty array as options and contentAfter to ComboBox when loading is true', () => { - const autocomplete = mountWithAppProvider( + it('does not render options when loading is true', () => { + const autocomplete = mountWithApp( ', () => { loading />, ); - expect(autocomplete.find(ComboBox).prop('options')).toStrictEqual([]); - expect(autocomplete.find(ComboBox).prop('contentAfter')).not.toBeNull(); + + expect(autocomplete).not.toContainReactComponent(ListBox.Option); }); }); describe('onLoadMoreResults', () => { it('gets called when then end of the option list is reached', () => { const spy = jest.fn(); - const autocomplete = mountWithAppProvider( + const autocomplete = mountWithApp( ', () => { />, ); - const comboBox = autocomplete.find(ComboBox); - trigger(comboBox, 'onEndReached'); + autocomplete.find(ComboBox)?.trigger('onScrolledToBottom'); expect(spy).toHaveBeenCalledTimes(1); }); @@ -155,18 +495,19 @@ describe('', () => { ); } - - function handleOnSelect(this: any, updatedSelection: string[]) { - const selectedText = updatedSelection.map((selectedItem: string) => { - const matchedOption = this.options.filter((option: any) => { - return option.value.match(selectedItem); - }); - return matchedOption[0] && matchedOption[0].label; - }); - if (this.ALLOW_MULTIPLE) { - this.setState({selected: updatedSelection}); - } else { - this.setState({selected: selectedText, inputText: selectedText}); - } - } }); + +function triggerFocus(combobox: ReactTestingElement | null) { + combobox && + combobox + .find(ComboBoxTextFieldContext.Provider)! + .triggerKeypath('value.onTextFieldFocus'); +} + +function triggerOnSelect( + autocomplete: CustomRoot | null, + values: string, +) { + const listbox = autocomplete!.find(ListBox); + listbox!.trigger('onSelect', values); +} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index f4eb6156109..dd5f25296eb 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -1,4 +1,10 @@ -import React, {forwardRef, useRef, useImperativeHandle, useState} from 'react'; +import React, { + forwardRef, + useRef, + useImperativeHandle, + useState, + useContext, +} from 'react'; import {MinusMinor, TickSmallMinor} from '@shopify/polaris-icons'; import {classNames} from '../../utilities/css'; @@ -8,6 +14,7 @@ import {Choice, helpTextID} from '../Choice'; import {errorTextID} from '../InlineError'; import {Icon} from '../Icon'; import {Error, Key, CheckboxHandles} from '../../types'; +import {WithinListBoxContext} from '../../utilities/list-box/context'; import styles from './Checkbox.scss'; @@ -67,6 +74,7 @@ export const Checkbox = forwardRef( setFalse: handleMouseOut, } = useToggle(false); const [keyFocused, setKeyFocused] = useState(false); + const isWithinListBox = useContext(WithinListBoxContext); useImperativeHandle(ref, () => ({ focus: () => { @@ -134,7 +142,6 @@ export const Checkbox = forwardRef( ); return ( - /* eslint-disable jsx-a11y/no-redundant-roles */ ( onChange={noop} aria-invalid={error != null} aria-describedby={ariaDescribedBy} - role="checkbox" + role={isWithinListBox ? 'presentation' : 'checkbox'} {...indeterminateAttributes} /> @@ -172,7 +179,6 @@ export const Checkbox = forwardRef( - /* eslint-enable jsx-a11y/no-redundant-roles */ ); }, ); diff --git a/src/components/ComboBox/ComboBox.scss b/src/components/ComboBox/ComboBox.scss new file mode 100644 index 00000000000..977141bcbff --- /dev/null +++ b/src/components/ComboBox/ComboBox.scss @@ -0,0 +1,6 @@ +@import '../../styles/common'; + +.ListBox { + padding: spacing(tight) 0; + overflow: visible; +} diff --git a/src/components/ComboBox/ComboBox.tsx b/src/components/ComboBox/ComboBox.tsx new file mode 100644 index 00000000000..1941e903b91 --- /dev/null +++ b/src/components/ComboBox/ComboBox.tsx @@ -0,0 +1,153 @@ +import React, {useState, useCallback, useMemo, Children} from 'react'; + +import {Popover} from '../Popover'; +import type {PopoverProps} from '../Popover'; +import type {TextFieldProps} from '../TextField'; +import type {ListBoxProps} from '../ListBox'; +import { + ComboBoxTextFieldContext, + ComboBoxTextFieldType, + ComboBoxListBoxContext, + ComboBoxListBoxType, + ComboBoxListBoxOptionType, + ComboBoxListBoxOptionContext, +} from '../../utilities/combo-box'; + +import styles from './ComboBox.scss'; +import {TextField} from './components'; + +export interface ComboBoxProps { + children?: React.ReactElement | null; + activator: React.ReactElement; + allowMultiple?: boolean; + onScrolledToBottom?(): void; + preferredPosition?: PopoverProps['preferredPosition']; +} + +export function ComboBox({ + children, + activator, + allowMultiple, + onScrolledToBottom, + preferredPosition = 'below', +}: ComboBoxProps) { + const [popoverActive, setPopoverActive] = useState(false); + const [activeOptionId, setActiveOptionId] = useState(); + const [textFieldLabelId, setTextFieldLabelId] = useState(); + const [listBoxId, setListBoxId] = useState(); + const [textFieldFocused, setTextFieldFocused] = useState(false); + const shouldOpen = Boolean(!popoverActive && Children.count(children) > 0); + + const onOptionSelected = useCallback(() => { + if (!allowMultiple) { + setPopoverActive(false); + setActiveOptionId(undefined); + } + }, [allowMultiple]); + + const handleClose = useCallback(() => { + setPopoverActive(false); + setActiveOptionId(undefined); + }, []); + + const handleFocus = useCallback(() => { + if (shouldOpen) { + setPopoverActive(true); + } + }, [shouldOpen]); + + const handleChange = useCallback(() => { + if (shouldOpen) { + setPopoverActive(true); + } + }, [shouldOpen]); + + const handleBlur = useCallback(() => { + if (popoverActive) { + setPopoverActive(false); + setActiveOptionId(undefined); + } + }, [popoverActive]); + + const textFieldContextValue: ComboBoxTextFieldType = useMemo( + () => ({ + activeOptionId, + expanded: popoverActive, + listBoxId, + setTextFieldFocused, + setTextFieldLabelId, + onTextFieldFocus: handleFocus, + onTextFieldChange: handleChange, + onTextFieldBlur: handleBlur, + }), + [ + activeOptionId, + popoverActive, + listBoxId, + setTextFieldFocused, + setTextFieldLabelId, + handleFocus, + handleChange, + handleBlur, + ], + ); + + const listBoxOptionContextValue: ComboBoxListBoxOptionType = useMemo( + () => ({ + allowMultiple, + }), + [allowMultiple], + ); + + const listBoxContextValue: ComboBoxListBoxType = useMemo( + () => ({ + setActiveOptionId, + setListBoxId, + listBoxId, + textFieldLabelId, + onOptionSelected, + textFieldFocused, + onKeyToBottom: onScrolledToBottom, + }), + [ + setActiveOptionId, + setListBoxId, + listBoxId, + textFieldLabelId, + onOptionSelected, + textFieldFocused, + onScrolledToBottom, + ], + ); + + return ( + + {activator} + + } + autofocusTarget="none" + preventFocusOnClose + fullWidth + preferInputActivator={false} + preferredPosition={preferredPosition} + > + + {Children.count(children) > 0 ? ( + + +
{children}
+
+
+ ) : null} +
+
+ ); +} + +ComboBox.TextField = TextField; diff --git a/src/components/ComboBox/README.md b/src/components/ComboBox/README.md new file mode 100644 index 00000000000..b6e8c0cfd4e --- /dev/null +++ b/src/components/ComboBox/README.md @@ -0,0 +1,420 @@ +--- +name: ComboBox +category: Forms +keywords: + - autocomplete + - searchable + - typeahead + - combobox + - listbox +--- + +# ComboBox + +The `ComboBox` component implements part of the [Aria 1.2 combobox](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox) specs on a TextField and a popover containing a ListBox. Like `Autocomplete`, `ComboBox` allows merchants to quickly search through and select from large collections of options. + +--- + +## Best practices + +The `ComboBox` component should: + +- Be clearly labeled so it’s noticeable to the merchant what type of options will be available +- Not be used within a popover +- Indicate a loading state to the merchant while option data is being populated + +--- + +## Content guidelines + +The input field for `ComboBox` should follow the [content guidelines](https://polaris.shopify.com/components/forms/text-field) for text fields. + +--- + +## Examples + +### Basic autocomplete + +Use to help merchants complete text input quickly from a list of options. + +```jsx +function ComboboxExample() { + const deselectedOptions = useMemo( + () => [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ], + [], + ); + + const [selectedOption, setSelectedOption] = useState(); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (value === '') { + setOptions(deselectedOptions); + return; + } + + const filterRegex = new RegExp(value, 'i'); + const resultOptions = deselectedOptions.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + }, + [deselectedOptions], + ); + + const updateSelection = useCallback( + (selected) => { + const matchedOption = options.find((option) => { + return option.value.match(selected); + }); + + setSelectedOption(selected); + setInputValue((matchedOption && matchedOption.label) || ''); + }, + [options], + ); + + const optionsMarkup = + options.length > 0 + ? options.map((option) => { + const {label, value} = option; + + return ( + + {label} + + ); + }) + : null; + + return ( +
+ } + onChange={updateText} + label="Search customers" + labelHidden + value={inputValue} + placeholder="Search customers" + /> + } + > + {options.length > 0 ? ( + {optionsMarkup} + ) : null} + +
+ ); +} +``` + +### Multiple tags autocomplete + +Use to help merchants select multiple options from a list curated by the text input. + +```jsx +function MultiComboboxExample() { + const deselectedOptions = useMemo( + () => [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ], + [], + ); + + const [selectedOptions, setSelectedOptions] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (value === '') { + setOptions(deselectedOptions); + return; + } + + const filterRegex = new RegExp(value, 'i'); + const resultOptions = deselectedOptions.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + }, + [deselectedOptions], + ); + + const updateSelection = useCallback( + (selected) => { + if (selectedOptions.includes(selected)) { + setSelectedOptions( + selectedOptions.filter((option) => option !== selected), + ); + } else { + setSelectedOptions([...selectedOptions, selected]); + } + + const matchedOption = options.find((option) => { + return option.value.match(selected); + }); + setInputValue((matchedOption && matchedOption.label) || ''); + }, + [options, selectedOptions], + ); + + const removeTag = useCallback( + (tag) => () => { + const options = [...selectedOptions]; + options.splice(options.indexOf(tag), 1); + setSelectedOptions(options); + }, + [selectedOptions], + ); + + const tagsMarkup = selectedOptions.map((option) => { + let tagLabel = ''; + tagLabel = option.replace('_', ' '); + tagLabel = titleCase(tagLabel); + return ( + + {tagLabel} + + ); + }); + + const optionsMarkup = + options.length > 0 + ? options.map((option) => { + const {label, value} = option; + + return ( + + {label} + + ); + }) + : null; + + return ( +
+ } + onChange={updateText} + label="Search customers" + labelHidden + value={inputValue} + placeholder="Search customers" + /> + } + > + {optionsMarkup ? ( + {optionsMarkup} + ) : null} + + + {tagsMarkup} + +
+ ); + + function titleCase(string) { + return string + .toLowerCase() + .split(' ') + .map((word) => word.replace(word[0], word[0].toUpperCase())) + .join(''); + } +} +``` + +### Autocomplete with loading + +Use to indicate loading state to merchants while option data is processing. + +```jsx +function LoadingAutocompleteExample() { + const deselectedOptions = useMemo( + () => [ + {value: 'rustic', label: 'Rustic'}, + {value: 'antique', label: 'Antique'}, + {value: 'vinyl', label: 'Vinyl'}, + {value: 'vintage', label: 'Vintage'}, + {value: 'refurbished', label: 'Refurbished'}, + ], + [], + ); + + const [selectedOption, setSelectedOption] = useState(); + const [inputValue, setInputValue] = useState(''); + const [options, setOptions] = useState(deselectedOptions); + const [loading, setLoading] = useState(false); + + const updateText = useCallback( + (value) => { + setInputValue(value); + + if (!loading) { + setLoading(true); + } + + setTimeout(() => { + if (value === '') { + setOptions(deselectedOptions); + setLoading(false); + return; + } + const filterRegex = new RegExp(value, 'i'); + const resultOptions = options.filter((option) => + option.label.match(filterRegex), + ); + setOptions(resultOptions); + setLoading(false); + }, 300); + }, + [deselectedOptions, loading, options], + ); + + const updateSelection = useCallback( + (selected) => { + const matchedOption = options.find((option) => { + return option.value.match(selected); + }); + + setSelectedOption(selected); + setInputValue((matchedOption && matchedOption.label) || ''); + }, + [options], + ); + + const optionsMarkup = + options.length > 0 + ? options.map((option) => { + const {label, value} = option; + + return ( + + {label} + + ); + }) + : null; + + const loadingMarkup = loading ? : null; + + const listBoxMarkup = + optionsMarkup || loadingMarkup ? ( + + {optionsMarkup && !loading ? optionsMarkup : null} + {loadingMarkup} + + ) : null; + + return ( + } + onChange={updateText} + label="Search customers" + labelHidden + value={inputValue} + placeholder="Search customers" + /> + } + > + {listBoxMarkup} + + ); +} +``` + +--- + +## Related components + +- For an input field without suggested options, [use the text field component](https://polaris.shopify.com/components/forms/text-field) +- For a list of selectable options not linked to an input field, [use the list box component](https://polaris.shopify.com/components/lists-and-tables/list-box) +- [Autocomplete](https://polaris.shopify.com/components/forms/autocomplete) can be used as a convenience wrapper in lieu of `ComboBox` and `ListBox`. + +--- + +## Accessibility + + + +See Material Design and development documentation about accessibility for Android: + +- [Accessible design on Android](https://material.io/design/usability/accessibility.html) +- [Accessible development on Android](https://developer.android.com/guide/topics/ui/accessibility/) + + + + + +See Apple’s Human Interface Guidelines and API documentation about accessibility for iOS: + +- [Accessible design on iOS](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessibility/) +- [Accessible development on iOS](https://developer.apple.com/accessibility/ios/) + + + + + +### Structure + +The `ComboBox` component is based on the [ARIA 1.2 combobox pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox). It is a combination of a single-line `TextField` and a `Popover`. The current implementation expects a [`ListBox`] component to be used. + +The `ComboBox` popover displays below the text field or other control by default so it is easy for merchants to discover and use. However, you can change the position with the `preferredPosition` prop. + +`ComboBox` features can be challenging for merchants with visual, motor, and cognitive disabilities. Even when they’re built using best practices, these features can be difficult to use with some assistive technologies. Merchants should always be able to search, enter data, or perform other activities without relying on the combobox. + + + +#### Do + +- Use combobox as progressive enhancement to make the interface easier to use for most merchants. + +#### Don’t + +- Require that merchants make a selection from the combobox to complete a task. + + + +### Keyboard support + +- Give the combobox's text input keyboard focus with the tab key (or shift + tab when tabbing backwards) + + diff --git a/src/components/ComboBox/components/TextField/TextField.tsx b/src/components/ComboBox/components/TextField/TextField.tsx new file mode 100644 index 00000000000..14c9831b492 --- /dev/null +++ b/src/components/ComboBox/components/TextField/TextField.tsx @@ -0,0 +1,78 @@ +import React, {useMemo, useCallback, useEffect} from 'react'; + +import {labelID} from '../../../Label'; +import {useUniqueId} from '../../../../utilities/unique-id'; +import {TextField as PolarisTextField} from '../../../TextField'; +import type {TextFieldProps} from '../../../TextField'; +import {useComboBoxTextField} from '../../../../utilities/combo-box'; + +export function TextField({ + value, + id: idProp, + onFocus, + onBlur, + onChange, + ...rest +}: TextFieldProps) { + const comboboxTextFieldContext = useComboBoxTextField(); + + const { + activeOptionId, + listBoxId, + expanded, + setTextFieldFocused, + setTextFieldLabelId, + onTextFieldFocus, + onTextFieldChange, + onTextFieldBlur, + } = comboboxTextFieldContext; + + const uniqueId = useUniqueId('ComboBoxTextField'); + const textFieldId = useMemo(() => idProp || uniqueId, [uniqueId, idProp]); + + const labelId = useMemo(() => labelID(idProp || uniqueId), [ + uniqueId, + idProp, + ]); + + useEffect(() => { + if (setTextFieldLabelId) setTextFieldLabelId(labelId); + }, [labelId, setTextFieldLabelId]); + + const handleFocus = useCallback(() => { + if (onFocus) onFocus(); + if (onTextFieldFocus) onTextFieldFocus(); + if (setTextFieldFocused) setTextFieldFocused(true); + }, [onFocus, onTextFieldFocus, setTextFieldFocused]); + + const handleBlur = useCallback(() => { + if (onBlur) onBlur(); + if (onTextFieldBlur) onTextFieldBlur(); + if (setTextFieldFocused) setTextFieldFocused(false); + }, [onBlur, onTextFieldBlur, setTextFieldFocused]); + + const handleChange = useCallback( + (value: string, id: string) => { + if (onChange) onChange(value, id); + if (onTextFieldChange) onTextFieldChange(); + }, + [onChange, onTextFieldChange], + ); + + return ( + + ); +} diff --git a/src/components/ComboBox/components/TextField/index.ts b/src/components/ComboBox/components/TextField/index.ts new file mode 100644 index 00000000000..35e9e6ed312 --- /dev/null +++ b/src/components/ComboBox/components/TextField/index.ts @@ -0,0 +1 @@ +export {TextField} from './TextField'; diff --git a/src/components/ComboBox/components/TextField/tests/TextField.test.tsx b/src/components/ComboBox/components/TextField/tests/TextField.test.tsx new file mode 100644 index 00000000000..3f3ca4b9fc9 --- /dev/null +++ b/src/components/ComboBox/components/TextField/tests/TextField.test.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {TextField as PolarisTextField} from '../../../../TextField'; +import type {TextFieldProps} from '../../../../TextField'; +import {TextField} from '../TextField'; +import {labelID} from '../../../../Label'; +import { + ComboBoxTextFieldContext, + ComboBoxTextFieldType, +} from '../../../../../utilities/combo-box'; + +const textFieldContextDefaultValue = { + activeOptionId: undefined, + listBoxId: undefined, + expanded: false, + setTextFieldLabelId: noop, + setTextFieldFocused: noop, + onTextFieldFocus: noop, + onTextFieldBlur: noop, + onTextFieldChange: noop, +}; + +function mountWithProvider( + props: { + textFieldProps?: Partial; + textFieldProviderValue?: Partial; + } = {}, +) { + const providerValue = { + ...textFieldContextDefaultValue, + ...props.textFieldProviderValue, + }; + + const textField = mountWithApp( + + + , + ); + + return textField; +} + +describe('ComboBox.TextField', () => { + it('throws if not wrapped in ComboBoxTextFieldContext', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => + mountWithApp( + , + ), + ).toThrow('No ComboBox was provided.'); + + consoleErrorSpy.mockRestore(); + }); + + it('renders a PolarisTextField', () => { + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + }); + + expect(combobox).toContainReactComponent(PolarisTextField, { + value: 'value', + autoComplete: 'off', + id: 'textFieldId', + onFocus: expect.any(Function), + onBlur: expect.any(Function), + onChange: expect.any(Function), + ariaAutocomplete: 'list', + ariaActiveDescendant: undefined, + ariaControls: undefined, + role: 'combobox', + ariaExpanded: false, + }); + }); + + it('passes the activeOptionId to the aria-activedescendant of the PolarisTextField', () => { + const activeOptionId = 'activeOptionId'; + const combobox = mountWithProvider({ + textFieldProviderValue: { + activeOptionId, + }, + }); + + expect(combobox).toContainReactComponent(PolarisTextField, { + ariaActiveDescendant: activeOptionId, + }); + }); + + it('passes the listBoxId to the aria-controls of the PolarisTextField', () => { + const listBoxId = 'listBoxId'; + const combobox = mountWithProvider({ + textFieldProviderValue: { + listBoxId, + }, + }); + + expect(combobox).toContainReactComponent(PolarisTextField, { + ariaControls: listBoxId, + }); + }); + + it('passes the expanded to the aria-expanded of the PolarisTextField', () => { + const combobox = mountWithProvider({ + textFieldProviderValue: { + expanded: true, + }, + }); + + expect(combobox).toContainReactComponent(PolarisTextField, { + ariaExpanded: true, + }); + }); + + it('calls setTextFieldLabelId with the expected ID', () => { + const textFieldId = 'textFieldId'; + const setTextFieldLabelIdSpy = jest.fn(); + const expectedId = labelID(textFieldId); + mountWithProvider({ + textFieldProps: { + id: textFieldId, + }, + textFieldProviderValue: { + setTextFieldLabelId: setTextFieldLabelIdSpy, + }, + }); + + expect(setTextFieldLabelIdSpy).toHaveBeenCalledWith(expectedId); + }); + + describe('onFocus', () => { + it('calls the onFocus prop on focus', () => { + const onFocusSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + onFocus: onFocusSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onFocus'); + + expect(onFocusSpy).toHaveBeenCalled(); + }); + + it('calls the onTextFieldFocus on Context', () => { + const onTextFieldFocusSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + textFieldProviderValue: { + onTextFieldFocus: onTextFieldFocusSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onFocus'); + + expect(onTextFieldFocusSpy).toHaveBeenCalled(); + }); + + it('calls the setTextFieldFocused on Context', () => { + const setTextFieldFocusSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + textFieldProviderValue: { + setTextFieldFocused: setTextFieldFocusSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onFocus'); + + expect(setTextFieldFocusSpy).toHaveBeenCalled(); + }); + }); + + describe('onBlur', () => { + it('calls the onBlur prop', () => { + const onBlurSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + onBlur: onBlurSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onBlur'); + + expect(onBlurSpy).toHaveBeenCalled(); + }); + + it('calls the onTextFieldBlur on Context', () => { + const onTextFieldBlurSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + textFieldProviderValue: { + onTextFieldBlur: onTextFieldBlurSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onBlur'); + + expect(onTextFieldBlurSpy).toHaveBeenCalled(); + }); + }); + + describe('onChange', () => { + it('calls the onChange prop', () => { + const onChangeSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + onChange: onChangeSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onChange'); + + expect(onChangeSpy).toHaveBeenCalled(); + }); + + it('calls the onTextFieldChange on Context', () => { + const onTextFieldChangeSpy = jest.fn(); + const combobox = mountWithProvider({ + textFieldProps: { + value: 'value', + id: 'textFieldId', + }, + textFieldProviderValue: { + onTextFieldChange: onTextFieldChangeSpy, + }, + }); + combobox.find(PolarisTextField)!.trigger('onChange'); + + expect(onTextFieldChangeSpy).toHaveBeenCalled(); + }); + }); +}); + +function noop() {} diff --git a/src/components/ComboBox/components/index.ts b/src/components/ComboBox/components/index.ts new file mode 100644 index 00000000000..35e9e6ed312 --- /dev/null +++ b/src/components/ComboBox/components/index.ts @@ -0,0 +1 @@ +export {TextField} from './TextField'; diff --git a/src/components/ComboBox/index.ts b/src/components/ComboBox/index.ts new file mode 100644 index 00000000000..f50eeedf616 --- /dev/null +++ b/src/components/ComboBox/index.ts @@ -0,0 +1,2 @@ +export * from './ComboBox'; +export {TextField as ComboBoxTextField} from './components'; diff --git a/src/components/ComboBox/tests/ComboBox.test.tsx b/src/components/ComboBox/tests/ComboBox.test.tsx new file mode 100644 index 00000000000..e98356ff697 --- /dev/null +++ b/src/components/ComboBox/tests/ComboBox.test.tsx @@ -0,0 +1,373 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {TextField} from '../../TextField'; +import {ComboBox} from '../ComboBox'; +import {ListBox} from '../../ListBox'; +import {Popover} from '../../Popover'; +import { + ComboBoxTextFieldContext, + ComboBoxListBoxContext, +} from '../../../utilities/combo-box'; +import {Key} from '../../../types'; + +describe('', () => { + const activator = ( + + ); + const listBox = ( + + + + ); + + it('renders a Popover in the providers', () => { + const combobox = mountWithApp( + {listBox}, + ); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + onClose: expect.any(Function), + autofocusTarget: 'none', + fullWidth: true, + preferInputActivator: false, + }); + }); + + it('renders the activator in ComboBoxTextFieldContext provider', () => { + const combobox = mountWithApp( + {listBox}, + ); + + expect(combobox.find(ComboBoxTextFieldContext.Provider)).toHaveReactProps({ + children: activator, + }); + }); + + it('renders the popover children in a ComboBoxListBoxContext provider', () => { + const combobox = mountWithApp( + {listBox}, + ); + + triggerFocus(combobox); + + expect( + combobox.find(ComboBoxListBoxContext.Provider), + ).toContainReactComponent(ListBox); + }); + + it('does not open Popover when the ComboBoxTextFieldContext onTextFieldFocus and there are no children', () => { + const combobox = mountWithApp(); + + triggerFocus(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('renders an active Popover when the activator is focused and there are children', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + }); + + it('closes the Popover when onOptionSelected is triggered and allowMultiple is false', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + + triggerOptionSelected(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('does not close the Popover when onOptionSelected is triggered and allowMultiple is true and there are children', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + + combobox + .find(ComboBoxListBoxContext.Provider)! + .triggerKeypath('value.onOptionSelected'); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + }); + + it('calls the onScrolledToBottom when the Popovers onScrolledToBottom is triggered', () => { + const onScrolledToBottomSpy = jest.fn(); + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + combobox.find(Popover.Pane)!.trigger('onScrolledToBottom'); + + expect(onScrolledToBottomSpy).toHaveBeenCalled(); + }); + + it('closes the Popover when onClose is called', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + combobox.find(Popover)?.trigger('onClose'); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('opens the Popover when the TextField activator is changed', () => { + const activator = ( + + ); + const combobox = mountWithApp( + + + + + , + ); + + combobox.find(TextField)?.trigger('onChange'); + + expect(combobox).toContainReactComponent(Popover, { + active: true, + }); + }); + + it('closes the Popover when TextField is blurred', () => { + const activator = ( + + ); + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + combobox.find(TextField)?.trigger('onBlur'); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + describe('popover', () => { + it('defaults active to false', () => { + const combobox = mountWithApp(); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('has fullWidth', () => { + const combobox = mountWithApp(); + + expect(combobox).toContainReactComponent(Popover, { + fullWidth: true, + }); + }); + + it('has autofocusTarget of none', () => { + const combobox = mountWithApp(); + + expect(combobox).toContainReactComponent(Popover, { + autofocusTarget: 'none', + }); + }); + + it('sets active to false when escape is pressed', () => { + const activator = ( + + ); + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + combobox.act(() => { + dispatchKeyup(Key.Escape); + }); + + expect(combobox).toContainReactComponent(Popover, { + active: false, + }); + }); + + it('passes the preferredPosition', () => { + const preferredPosition = 'above'; + const combobox = mountWithApp( + , + ); + + expect(combobox).toContainReactComponent(Popover, { + preferredPosition, + }); + }); + }); + + describe('Context', () => { + it('sets expanded to true on the ComboBoxTextFieldContext when the popover is active', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + expect( + combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')! + .expanded, + ).toBe(true); + }); + + it('sets expanded to false on the ComboBoxTextFieldContext when the popover is not active', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + combobox + .find(ComboBoxListBoxContext.Provider)! + .triggerKeypath('value.onOptionSelected'); + + expect( + combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')! + .expanded, + ).toBe(false); + }); + + it('sets the activeOptionId on the ComboBoxTextFieldContext to undefined the popover is not closed', () => { + const combobox = mountWithApp( + + + + + , + ); + + triggerFocus(combobox); + + combobox + .find(ComboBoxListBoxContext.Provider)! + .triggerKeypath('value.setActiveOptionId', 'id'); + + expect( + combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')! + .activeOptionId, + ).toBe('id'); + + triggerOptionSelected(combobox); + + expect( + combobox.find(ComboBoxTextFieldContext.Provider)!.prop('value')! + .activeOptionId, + ).toBeUndefined(); + }); + }); +}); + +function triggerFocus(combobox: any) { + combobox + .find(ComboBoxTextFieldContext.Provider)! + .triggerKeypath('value.onTextFieldFocus'); +} + +function triggerOptionSelected(combobox: any) { + combobox + .find(ComboBoxListBoxContext.Provider)! + .triggerKeypath('value.onOptionSelected'); +} + +function noop() {} + +function dispatchKeyup(key: Key) { + const event: KeyboardEventInit & {keyCode: Key} = {keyCode: key}; + document.dispatchEvent(new KeyboardEvent('keyup', event)); +} diff --git a/src/components/ListBox/ListBox.scss b/src/components/ListBox/ListBox.scss new file mode 100644 index 00000000000..7355fd7b068 --- /dev/null +++ b/src/components/ListBox/ListBox.scss @@ -0,0 +1,10 @@ +.ListBox { + padding: 0; + margin: 0; + list-style: none; + max-width: 100%; + + &:focus { + outline: none; + } +} diff --git a/src/components/ListBox/ListBox.tsx b/src/components/ListBox/ListBox.tsx new file mode 100644 index 00000000000..c2b062c6482 --- /dev/null +++ b/src/components/ListBox/ListBox.tsx @@ -0,0 +1,330 @@ +import React, { + useState, + useRef, + useEffect, + useCallback, + ReactNode, + useMemo, +} from 'react'; +import debounce from 'lodash/debounce'; + +import {classNames} from '../../utilities/css'; +import {useToggle} from '../../utilities/use-toggle'; +import {useUniqueId} from '../../utilities/unique-id'; +import {Key} from '../../types'; +import {KeypressListener} from '../KeypressListener'; +import {VisuallyHidden} from '../VisuallyHidden'; +import {useComboBoxListBox} from '../../utilities/combo-box'; +import {closestParentMatch} from '../../utilities/closest-parent-match'; +import {scrollIntoView} from '../../utilities/scroll-into-view'; +import {ListBoxContext, WithinListBoxContext} from '../../utilities/list-box'; +import type {NavigableOption} from '../../utilities/list-box'; + +import { + Option, + Section, + Header, + Action, + Loading, + TextOption, + listBoxSectionDataSelector, +} from './components'; +import styles from './ListBox.scss'; + +export interface ListBoxProps { + /** Inner content of the listbox */ + children: ReactNode; + /** Explicitly enable keyboard control */ + enableKeyboardControl?: boolean; + /** Visually hidden text for screen readers */ + accessibilityLabel?: string; + /** Callback when an option is selected */ + onSelect?(value: string): void; +} + +export type ArrowKeys = 'up' | 'down'; + +export const scrollable = { + props: {'data-polaris-scrollable': true}, + selector: '[data-polaris-scrollable]', +}; + +const LISTBOX_OPTION_SELECTOR = '[data-listbox-option]'; +const LISTBOX_OPTION_VALUE_ATTRIBUTE = 'data-listbox-option-value'; + +const DATA_ATTRIBUTE = 'data-focused'; + +export function ListBox({ + children, + enableKeyboardControl, + accessibilityLabel, + onSelect, +}: ListBoxProps) { + const listBoxClassName = classNames(styles.ListBox); + const { + value: keyboardEventsEnabled, + setTrue: enableKeyboardEvents, + setFalse: disableKeyboardEvents, + } = useToggle(Boolean(enableKeyboardControl)); + const listId = useUniqueId('ListBox'); + const scrollableRef = useRef(null); + const listBoxRef = useRef(null); + const [loading, setLoading] = useState(); + const [currentActiveOption, setCurrentActiveOption] = useState< + NavigableOption + >(); + const { + setActiveOptionId, + setListBoxId, + listBoxId, + textFieldLabelId, + onOptionSelected, + onKeyToBottom, + textFieldFocused, + } = useComboBoxListBox(); + + const inComboBox = Boolean(setActiveOptionId); + + useEffect(() => { + if (setListBoxId && !listBoxId) { + setListBoxId(listId); + } + }, [setListBoxId, listBoxId, listId]); + + useEffect(() => { + if (!currentActiveOption || !setActiveOptionId) return; + setActiveOptionId(currentActiveOption.domId); + }, [currentActiveOption, setActiveOptionId]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleScrollIntoView = useCallback( + debounce((option: NavigableOption, first: boolean) => { + if (scrollableRef.current) { + const {element} = option; + const focusTarget = first + ? closestParentMatch(element, listBoxSectionDataSelector.selector) || + element + : element; + + scrollIntoView(focusTarget, scrollableRef.current); + } + }, 15), + [], + ); + + const handleChangeActiveOption = useCallback( + (nextOption?: NavigableOption) => { + setCurrentActiveOption((currentActiveOption) => { + if (currentActiveOption) { + currentActiveOption.element.removeAttribute(DATA_ATTRIBUTE); + } + + if (nextOption) { + nextOption.element.setAttribute(DATA_ATTRIBUTE, 'true'); + if (scrollableRef.current) { + const first = + getNavigableOptions().findIndex( + (element) => element.id === nextOption.element.id, + ) === 0; + + handleScrollIntoView(nextOption, first); + } + return nextOption; + } else { + return undefined; + } + }); + }, + [handleScrollIntoView], + ); + + useEffect(() => { + if (listBoxRef.current) { + scrollableRef.current = listBoxRef.current.closest(scrollable.selector); + } + }, []); + + useEffect(() => { + if (enableKeyboardControl && !keyboardEventsEnabled) { + enableKeyboardEvents(); + } + }, [enableKeyboardControl, keyboardEventsEnabled, enableKeyboardEvents]); + + const onOptionSelect = useCallback( + (option: NavigableOption) => { + handleChangeActiveOption(option); + + if (onOptionSelected) { + onOptionSelected(); + } + if (onSelect) onSelect(option.value); + }, + [handleChangeActiveOption, onSelect, onOptionSelected], + ); + + const listBoxContext = useMemo( + () => ({ + onOptionSelect, + setLoading, + }), + [onOptionSelect], + ); + + function findNextValidOption(type: ArrowKeys) { + const isUp = type === 'up'; + const navItems = getNavigableOptions(); + let nextElement: HTMLElement | null | undefined = + currentActiveOption?.element; + let count = -1; + + while (count++ < navItems.length) { + let nextIndex; + if (nextElement) { + const currentId = nextElement?.id; + const currentIndex = navItems.findIndex( + (currentNavItem) => currentNavItem.id === currentId, + ); + + let increment = isUp ? -1 : 1; + if (currentIndex === 0 && isUp) { + increment = navItems.length - 1; + } else if (currentIndex === navItems.length - 1 && !isUp) { + increment = -(navItems.length - 1); + } + + nextIndex = currentIndex + increment; + nextElement = navItems[nextIndex]; + } else { + nextIndex = isUp ? navItems.length - 1 : 0; + nextElement = navItems[nextIndex]; + } + + if (nextElement?.getAttribute('aria-disabled') === 'true') continue; + + if (nextIndex === navItems.length - 1 && onKeyToBottom) { + onKeyToBottom(); + } + return nextElement; + } + + return null; + } + + function handleArrow(type: ArrowKeys, evt: KeyboardEvent) { + evt.preventDefault(); + + const nextValidElement = findNextValidOption(type); + + if (!nextValidElement) return; + + const nextOption = { + domId: nextValidElement.id, + value: + nextValidElement.getAttribute(LISTBOX_OPTION_VALUE_ATTRIBUTE) || '', + element: nextValidElement, + disabled: nextValidElement.getAttribute('aria-disabled') === 'true', + }; + + handleChangeActiveOption(nextOption); + } + + function handleDownArrow(evt: KeyboardEvent) { + handleArrow('down', evt); + } + + function handleUpArrow(evt: KeyboardEvent) { + handleArrow('up', evt); + } + + function handleEnter(evt: KeyboardEvent) { + evt.preventDefault(); + evt.stopPropagation(); + if (currentActiveOption) { + onOptionSelect(currentActiveOption); + } + } + + function handleFocus() { + if (enableKeyboardControl) return; + enableKeyboardEvents(); + } + + function handleBlur(event: React.FocusEvent) { + event.stopPropagation(); + if (keyboardEventsEnabled) { + handleChangeActiveOption(); + } + if (enableKeyboardControl) return; + disableKeyboardEvents(); + } + + const listeners = + keyboardEventsEnabled || textFieldFocused ? ( + <> + + + + + ) : null; + + return ( + <> + {listeners} + +
{loading ? loading : null}
+
+ + + {children ? ( +
    + {children} +
+ ) : null} +
+
+ + ); + + function getNavigableOptions() { + return [ + ...new Set( + listBoxRef.current?.querySelectorAll( + LISTBOX_OPTION_SELECTOR, + ), + ), + ]; + } +} + +ListBox.Option = Option; +ListBox.TextOption = TextOption; +ListBox.Loading = Loading; +ListBox.Section = Section; +ListBox.Header = Header; +ListBox.Action = Action; diff --git a/src/components/ListBox/README.md b/src/components/ListBox/README.md new file mode 100644 index 00000000000..c742a952b6c --- /dev/null +++ b/src/components/ListBox/README.md @@ -0,0 +1,180 @@ +--- +name: ListBox +category: Lists and tables +keywords: + - list + - listbox + - interactive list +--- + +# ListBox + +The `ListBox` component is a list component that implements part of the [Aria 1.2 ListBox specs](https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox). It presents a list of options and allows users to select one or more of them. If you need more structure than the standard component offers, use composition to customize the presentation of these lists by using headers or custom elements. + +--- + +## Best practices + +Listboxes should: + +- Be clearly labeled so it’s noticeable to the merchant what type of options will be available +- Limit the number of options displayed at once +- Indicate a loading state to the merchant while option data is being populated + +--- + +## Content guidelines + +### Option lists + +Each item in a `ListBox` should be clear and descriptive. + + + +#### Do + +- Traffic referrer source + +#### Don’t + +- Source + + + +--- + +## Examples + +### Basic ListBox + +Basic implementation of a control element used to let merchants select options + +```jsx +function BaseListBoxExample() { + return ( + + Item 1 + Item 2 + Item 3 + + ); +} +``` + +### ListBox with Loading + +Implementation of a control element showing a loading indicator to let merchants know more options are being loaded + +```jsx +function ListBoxWithLoadingExample() { + return ( + + Item 1 + Item 2 + Item 3 + + + ); +} +``` + +### ListBox with Action + +Implementation of a control element used to let merchants take an action + +```jsx +function ListBoxWithActionExample() { + return ( + + +
Add item
+
+ Item 1 + Item 2 +
+ ); +} +``` + +### ListBox with custom element + +Implementation of a control with custom rendering of options + +```jsx +function ListBoxWithCustomElementExample() { + return ( + + + Add item + + +
Item 1
+
+ +
Item 2
+
+ +
Item 3
+
+ +
+ ); +} +``` + +--- + +## Related components + +- For a text field and popover container, [use the combobox component](https://polaris.shopify.com/components/forms/combobox) +- [Autocomplete](https://polaris.shopify.com/components/forms/autocomplete) can be used as a convenience wrapper in lieu of ComboBox and ListBox. + +--- + +## Accessibility + + + +See Material Design and development documentation about accessibility for Android: + +- [Accessible design on Android](https://material.io/design/usability/accessibility.html) +- [Accessible development on Android](https://developer.android.com/guide/topics/ui/accessibility/) + + + + + +See Apple’s Human Interface Guidelines and API documentation about accessibility for iOS: + +- [Accessible design on iOS](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessibility/) +- [Accessible development on iOS](https://developer.apple.com/accessibility/ios/) + + + + + +### Structure + +The `ListBox` component is based on the [Aria 1.2 ListBox pattern](https://www.w3.org/TR/wai-aria-practices-1.2/#Listbox). + +It is important to not present interactive elements inside of list box options as they can interfere with navigation +for assistive technology users. + + + +#### Do + +- Use labels + +#### Don’t + +- Use interactive elements inside the list + + + +### Keyboard support + +- Access the list of options with the up and down arrow keys +- Select an option that has focus with the enter/return key + + diff --git a/src/components/ListBox/components/Action/Action.scss b/src/components/ListBox/components/Action/Action.scss new file mode 100644 index 00000000000..4a896235601 --- /dev/null +++ b/src/components/ListBox/components/Action/Action.scss @@ -0,0 +1,10 @@ +@import '../../../../styles/common'; + +.Action { + display: flex; + flex: 1; +} + +.Icon { + padding-right: spacing(tight); +} diff --git a/src/components/ListBox/components/Action/Action.tsx b/src/components/ListBox/components/Action/Action.tsx new file mode 100644 index 00000000000..9494eb969fc --- /dev/null +++ b/src/components/ListBox/components/Action/Action.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import {Icon} from '../../../Icon'; +import type {IconProps} from '../../../Icon'; +import {Option, OptionProps} from '../Option'; +import {TextOption} from '../TextOption'; + +import styles from './Action.scss'; + +interface ActionProps extends OptionProps { + icon?: IconProps['source']; +} + +export function Action(props: ActionProps) { + const {selected, disabled, children, icon} = props; + + const iconMarkup = icon && ( +
+ +
+ ); + + return ( + + ); +} diff --git a/src/components/ListBox/components/Action/index.ts b/src/components/ListBox/components/Action/index.ts new file mode 100644 index 00000000000..b9a68fea5ab --- /dev/null +++ b/src/components/ListBox/components/Action/index.ts @@ -0,0 +1 @@ +export {Action} from './Action'; diff --git a/src/components/ListBox/components/Action/tests/Action.test.tsx b/src/components/ListBox/components/Action/tests/Action.test.tsx new file mode 100644 index 00000000000..4f6cdbc6da1 --- /dev/null +++ b/src/components/ListBox/components/Action/tests/Action.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import {CirclePlusMinor, AddMajor} from '@shopify/polaris-icons'; +import {mountWithListBoxProvider} from 'test-utilities/list-box'; + +import {Action} from '../Action'; +import {Option} from '../../Option'; +import {TextOption} from '../../TextOption'; +import {Icon} from '../../../../Icon'; + +describe('Action', () => { + const defaultProps = { + value: 'value', + selected: false, + disabled: false, + accessibilityLabel: 'accessibility label', + }; + + it('passes props to Option', () => { + const action = mountWithListBoxProvider(); + + expect(action).toContainReactComponent(Option, defaultProps); + }); + + it('passes select, disabled from props to text option', () => { + const action = mountWithListBoxProvider( + , + ); + + expect(action).toContainReactComponent(TextOption, { + selected: true, + disabled: true, + }); + }); + + it('does not renders a default Icon', () => { + const action = mountWithListBoxProvider(); + + expect(action).not.toContainReactComponent(Icon, { + source: CirclePlusMinor, + }); + }); + + it('renders the Icon from the prop', () => { + const action = mountWithListBoxProvider( + , + ); + + expect(action).toContainReactComponent(Icon, { + source: AddMajor, + }); + }); + + it('renders the children', () => { + const label = 'test label'; + const action = mountWithListBoxProvider( + {label}, + ); + + expect(action).toContainReactText(label); + }); +}); diff --git a/src/components/ListBox/components/Header/Header.scss b/src/components/ListBox/components/Header/Header.scss new file mode 100644 index 00000000000..ed0b9fa754d --- /dev/null +++ b/src/components/ListBox/components/Header/Header.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/common'; + +.Header { + @include text-style-subheading; + padding: spacing(tight) spacing(base); + color: var(--p-text-subdued); +} diff --git a/src/components/ListBox/components/Header/Header.tsx b/src/components/ListBox/components/Header/Header.tsx new file mode 100644 index 00000000000..a2b1418c3c7 --- /dev/null +++ b/src/components/ListBox/components/Header/Header.tsx @@ -0,0 +1,26 @@ +import React, {ReactNode} from 'react'; + +import {useSection} from '../Section'; + +import styles from './Header.scss'; + +interface HeaderProps { + children: ReactNode; +} + +export function Header({children}: HeaderProps) { + const sectionId = useSection() || ''; + + const content = + typeof children === 'string' ? ( +
{children}
+ ) : ( + children + ); + + return ( +
+ {content} +
+ ); +} diff --git a/src/components/ListBox/components/Header/index.ts b/src/components/ListBox/components/Header/index.ts new file mode 100644 index 00000000000..5e4d6a204ba --- /dev/null +++ b/src/components/ListBox/components/Header/index.ts @@ -0,0 +1 @@ +export {Header} from './Header'; diff --git a/src/components/ListBox/components/Header/tests/Header.test.tsx b/src/components/ListBox/components/Header/tests/Header.test.tsx new file mode 100644 index 00000000000..9a279ab43a1 --- /dev/null +++ b/src/components/ListBox/components/Header/tests/Header.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {Header} from '../Header'; +import {Section} from '../../Section'; + +jest.mock('../../Section', () => ({ + ...jest.requireActual('../../Section'), + useSection: jest.fn(), +})); + +describe('Header', () => { + afterEach(() => { + mockUseSection(''); + }); + + it('renders an element with aria hidden', () => { + const header = mountWithApp(
Header
); + + expect(header).toContainReactComponent('div', {'aria-hidden': true}); + }); + + it('renders string headers with standard styling', () => { + const header = mountWithApp(
Header
); + + expect(header).toContainReactComponent('div', {className: 'Header'}); + }); + + it('renders headers without default wrapper when not type string', () => { + const header = mountWithApp( +
+ +
, + ); + + expect(header).toContainReactComponent('button'); + expect(header).not.toContainReactComponent('div', {className: 'Header'}); + }); + + it('renders an element with id from Section', () => { + const id = 'mock-id'; + mockUseSection(id); + const section = mountWithApp(
Header} />); + + expect(section).toContainReactComponent('div', { + id, + }); + }); +}); + +function mockUseSection(id: string) { + const useSection: jest.Mock = jest.requireMock('../../Section').useSection; + + useSection.mockReturnValue(id); +} diff --git a/src/components/ListBox/components/Loading/Loading.scss b/src/components/ListBox/components/Loading/Loading.scss new file mode 100644 index 00000000000..7bc69025c17 --- /dev/null +++ b/src/components/ListBox/components/Loading/Loading.scss @@ -0,0 +1,14 @@ +@import '../../../../styles/common'; + +$item-min-height: rem(40px); + +.ListItem { + padding: 0; + margin: 0; +} + +.Loading { + padding: spacing(tight) spacing(); + display: grid; + place-items: center; +} diff --git a/src/components/ListBox/components/Loading/Loading.tsx b/src/components/ListBox/components/Loading/Loading.tsx new file mode 100644 index 00000000000..1e0f43c8160 --- /dev/null +++ b/src/components/ListBox/components/Loading/Loading.tsx @@ -0,0 +1,37 @@ +import React, {memo, useEffect} from 'react'; + +import {Spinner} from '../../../Spinner'; +import {useListBox} from '../../../../utilities/list-box'; + +import styles from './Loading.scss'; + +export interface LoadingProps { + children?: React.ReactNode; + accessibilityLabel: string; +} + +export const Loading = memo(function LoadingOption({ + children, + accessibilityLabel: label, +}: LoadingProps) { + const {setLoading} = useListBox(); + + useEffect(() => { + setLoading(label); + return () => { + setLoading(undefined); + }; + }, [label, setLoading]); + + return ( +
  • + {children ? ( + children + ) : ( +
    + +
    + )} +
  • + ); +}); diff --git a/src/components/ListBox/components/Loading/index.ts b/src/components/ListBox/components/Loading/index.ts new file mode 100644 index 00000000000..b32e0f6f8fd --- /dev/null +++ b/src/components/ListBox/components/Loading/index.ts @@ -0,0 +1 @@ +export {Loading} from './Loading'; diff --git a/src/components/ListBox/components/Loading/tests/Loading.test.tsx b/src/components/ListBox/components/Loading/tests/Loading.test.tsx new file mode 100644 index 00000000000..7e56192d8c1 --- /dev/null +++ b/src/components/ListBox/components/Loading/tests/Loading.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import {mountWithApp} from 'test-utilities'; + +import {ListBoxContext} from '../../../../../utilities/list-box'; +import {Loading} from '../Loading'; +import {Spinner} from '../../../../Spinner'; + +const listBoxContext = { + addNavigableOption: noop, + updateNavigableOption: noop, + removeNavigableOption: noop, + onOptionSelect: noop, + setLoading: noop, +}; + +describe('Loading', () => { + const defaultProps = {accessibilityLabel: 'accessibility label'}; + + it('throws if not inside a listBox Context', () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => mountWithApp()).toThrow( + 'No ListBox was provided. ListBox components must be wrapped in a Listbox', + ); + + consoleErrorSpy.mockRestore(); + }); + + it('renders children instead of default spinner when passed', () => { + const accessibilityLabel = 'label'; + const customLoadingState = 'customLoadingState'; + const loading = mountWithApp( + + +
    {customLoadingState}
    +
    +
    , + ); + + expect(loading).toContainReactComponent('div', { + children: customLoadingState, + }); + expect(loading).not.toContainReactComponent(Spinner); + }); + + it('calls setLoading on context with the default loading text if there is no accessibilityLabel', () => { + const accessibilityLabel = 'label'; + const setLoadingSpy = jest.fn(); + const contextValue = { + ...listBoxContext, + setLoading: setLoadingSpy, + }; + mountWithApp( + + + , + ); + + expect(setLoadingSpy).toHaveBeenCalledWith(accessibilityLabel); + }); + + it('calls setLoading on context with the accessibilityLabel', () => { + const accessibilityLabel = 'label'; + const setLoadingSpy = jest.fn(); + const contextValue = { + ...listBoxContext, + setLoading: setLoadingSpy, + }; + mountWithApp( + + + , + ); + + expect(setLoadingSpy).toHaveBeenCalledWith(accessibilityLabel); + }); + + it('calls setLoading with undefined when it unmounts', () => { + const setLoadingSpy = jest.fn(); + const contextValue = { + ...listBoxContext, + setLoading: setLoadingSpy, + }; + const listbox = mountWithApp( + + + , + ); + + listbox.find(Loading)!.root.unmount(); + + expect(setLoadingSpy).toHaveBeenCalledWith(undefined); + }); +}); + +function noop() {} diff --git a/src/components/ListBox/components/Option/Option.scss b/src/components/ListBox/components/Option/Option.scss new file mode 100644 index 00000000000..4a033a3785a --- /dev/null +++ b/src/components/ListBox/components/Option/Option.scss @@ -0,0 +1,15 @@ +@import '../../../../styles/common'; + +.Option { + display: flex; + margin: 0; + padding: 0; + + &:focus { + outline: none; + } +} + +.divider { + border-bottom: border('divider'); +} diff --git a/src/components/ListBox/components/Option/Option.tsx b/src/components/ListBox/components/Option/Option.tsx new file mode 100644 index 00000000000..d8a6b0d2e04 --- /dev/null +++ b/src/components/ListBox/components/Option/Option.tsx @@ -0,0 +1,110 @@ +import React, {useRef, useCallback, memo, useContext} from 'react'; + +import {classNames} from '../../../../utilities/css'; +import {useUniqueId} from '../../../../utilities/unique-id'; +import {useListBox} from '../../../../utilities/list-box'; +import {useSection, listBoxWithinSectionDataSelector} from '../Section'; +import {TextOption} from '../TextOption'; +import {UnstyledLink} from '../../../UnstyledLink'; +import {MappedActionContext} from '../../../../utilities/autocomplete'; + +import styles from './Option.scss'; + +export interface OptionProps { + // Unique item value + value: string; + // Visually hidden text for screen readers + accessibilityLabel?: string; + // Children. When a string, children are rendered in a styled TextOption + children?: string | React.ReactNode; + // Option is selected + selected?: boolean; + // Option is disabled + disabled?: boolean; + // Adds a border-bottom to the Option + divider?: boolean; +} + +export const Option = memo(function Option({ + value, + children, + selected, + disabled = false, + accessibilityLabel, + divider, +}: OptionProps) { + const {onOptionSelect} = useListBox(); + const {role, url, external, onAction, destructive, isAction} = useContext( + MappedActionContext, + ); + const listItemRef = useRef(null); + const domId = useUniqueId('ListBoxOption'); + const sectionId = useSection(); + const isWithinSection = Boolean(sectionId); + + const handleOptionClick = useCallback( + (evt: React.MouseEvent) => { + evt.preventDefault(); + onAction && onAction(); + if (onOptionSelect && listItemRef.current && !isAction) { + onOptionSelect({ + domId, + value, + element: listItemRef.current, + disabled, + }); + } + }, + [domId, onOptionSelect, value, disabled, onAction, isAction], + ); + + // prevents lost of focus on Textfield + const handleMouseDown = (evt: React.MouseEvent) => { + evt.preventDefault(); + }; + + const content = + typeof children === 'string' ? ( + + {children} + + ) : ( + children + ); + + const sectionAttributes = { + [listBoxWithinSectionDataSelector.attribute]: isWithinSection, + }; + + const legacyRoleSupport = role || 'option'; + + const contentMarkup = url ? ( + + {content} + + ) : ( + content + ); + + return ( +
  • + {contentMarkup} +
  • + ); +}); diff --git a/src/components/ListBox/components/Option/index.ts b/src/components/ListBox/components/Option/index.ts new file mode 100644 index 00000000000..e149829a484 --- /dev/null +++ b/src/components/ListBox/components/Option/index.ts @@ -0,0 +1 @@ +export * from './Option'; diff --git a/src/components/ListBox/components/Option/tests/Option.test.tsx b/src/components/ListBox/components/Option/tests/Option.test.tsx new file mode 100644 index 00000000000..4459b816904 --- /dev/null +++ b/src/components/ListBox/components/Option/tests/Option.test.tsx @@ -0,0 +1,380 @@ +import React from 'react'; +import {mount} from 'test-utilities'; +import {mountWithListBoxProvider} from 'test-utilities/list-box'; + +import type {ListBoxContext} from '../../../../../utilities/list-box'; +import {Option} from '../Option'; +import {TextOption} from '../../TextOption'; +import {MappedActionContext} from '../../../../../utilities/autocomplete'; +import {UnstyledLink} from '../../../../UnstyledLink'; + +jest.mock('components', () => ({ + ...jest.requireActual('components'), + Icon() { + return null; + }, +})); + +const defaultProps = { + accessibilityLabel: 'label', + value: 'value', +}; + +const defaultContext: React.ContextType = { + onOptionSelect: noop, + setLoading: noop, +}; + +describe('Option', () => { + it("throws when the Option does not have 'ListBoxContext'", () => { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const attemptMount = () => { + mount(