From ca40174b33ff3cb8a3a0a066d461b07bfa7f271d Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 14 Jun 2022 18:19:04 +0000 Subject: [PATCH 001/119] Add `useCombobox` hook, extending `@github/combobox-nav` --- docs/package-lock.json | 105 ++++++++++++++++++--------- package-lock.json | 11 +++ package.json | 1 + src/hooks/index.ts | 2 + src/hooks/useCombobox.ts | 148 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 35 deletions(-) create mode 100644 src/hooks/useCombobox.ts diff --git a/docs/package-lock.json b/docs/package-lock.json index 222db9c2f47..1dfd0931612 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -28720,7 +28720,8 @@ "ws": { "version": "7.4.5", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz", - "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==" + "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==", + "requires": {} } } }, @@ -29078,7 +29079,8 @@ "@mdx-js/react": { "version": "1.6.22", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz", - "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==" + "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==", + "requires": {} }, "@mdx-js/util": { "version": "1.6.22", @@ -29293,7 +29295,8 @@ "@primer/octicons-react": { "version": "16.3.1", "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-16.3.1.tgz", - "integrity": "sha512-uzTs8/CvLiW1/47cgMRkIK9bKWpnw+UonCbgczXErwSSLqMDHfiiTpobW1trvRuoiMgLwsPo0l7kBBdKBnmq3g==" + "integrity": "sha512-uzTs8/CvLiW1/47cgMRkIK9bKWpnw+UonCbgczXErwSSLqMDHfiiTpobW1trvRuoiMgLwsPo0l7kBBdKBnmq3g==", + "requires": {} }, "@primer/primitives": { "version": "4.1.0", @@ -29328,7 +29331,8 @@ "@primer/octicons-react": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-16.1.1.tgz", - "integrity": "sha512-xCxQ5z23ol7yDuJs85Lc4ARzyoay+b3zOhAKkEMU7chk0xi2hT2OnRP23QUudNNDPTGozX268RGYLexUa6P4xw==" + "integrity": "sha512-xCxQ5z23ol7yDuJs85Lc4ARzyoay+b3zOhAKkEMU7chk0xi2hT2OnRP23QUudNNDPTGozX268RGYLexUa6P4xw==", + "requires": {} }, "@primer/primitives": { "version": "7.6.0", @@ -29340,7 +29344,8 @@ "@radix-ui/react-polymorphic": { "version": "0.0.14", "resolved": "https://registry.npmjs.org/@radix-ui/react-polymorphic/-/react-polymorphic-0.0.14.tgz", - "integrity": "sha512-9nsMZEDU3LeIUeHJrpkkhZVxu/9Fc7P2g2I3WR+uA9mTbNC3hGaabi0dV6wg0CfHb+m4nSs1pejbE/5no3MJTA==" + "integrity": "sha512-9nsMZEDU3LeIUeHJrpkkhZVxu/9Fc7P2g2I3WR+uA9mTbNC3hGaabi0dV6wg0CfHb+m4nSs1pejbE/5no3MJTA==", + "requires": {} }, "@react-aria/ssr": { "version": "3.1.0", @@ -30474,7 +30479,8 @@ "acorn-jsx": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", - "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==" + "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", + "requires": {} }, "acorn-walk": { "version": "6.2.0", @@ -30509,12 +30515,14 @@ "ajv-errors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", - "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==" + "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", + "requires": {} }, "ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==" + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "requires": {} }, "alphanum-sort": { "version": "1.0.2", @@ -31041,7 +31049,8 @@ "babel-plugin-remove-graphql-queries": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/babel-plugin-remove-graphql-queries/-/babel-plugin-remove-graphql-queries-2.16.1.tgz", - "integrity": "sha512-PkHJuRodMp4p617a/ZVhV8elBhRoFpOTpdu2DaApXJFIsDJWhjZ8d4BGbbFCT/yKJrhRDTdqg1r5AhWEaEUKkw==" + "integrity": "sha512-PkHJuRodMp4p617a/ZVhV8elBhRoFpOTpdu2DaApXJFIsDJWhjZ8d4BGbbFCT/yKJrhRDTdqg1r5AhWEaEUKkw==", + "requires": {} }, "babel-plugin-styled-components": { "version": "2.0.2", @@ -33112,7 +33121,8 @@ "cssnano-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-2.0.1.tgz", - "integrity": "sha512-i8vLRZTnEH9ubIyfdZCAdIdgnHAUeQeByEeQ2I7oTilvP9oHO6RScpeq3GsFUVqeB8uZgOQ9pw8utofNn32hhQ==" + "integrity": "sha512-i8vLRZTnEH9ubIyfdZCAdIdgnHAUeQeByEeQ2I7oTilvP9oHO6RScpeq3GsFUVqeB8uZgOQ9pw8utofNn32hhQ==", + "requires": {} }, "csso": { "version": "4.2.0", @@ -33743,7 +33753,8 @@ "ws": { "version": "7.4.6", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", - "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==" + "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "requires": {} } } }, @@ -33767,7 +33778,8 @@ "ws": { "version": "7.4.6", "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", - "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==" + "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "requires": {} } } }, @@ -34489,7 +34501,8 @@ "eslint-plugin-react-hooks": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz", - "integrity": "sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ==" + "integrity": "sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ==", + "requires": {} }, "eslint-scope": { "version": "5.1.1", @@ -36270,7 +36283,8 @@ "babel-plugin-remove-graphql-queries": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/babel-plugin-remove-graphql-queries/-/babel-plugin-remove-graphql-queries-3.7.1.tgz", - "integrity": "sha512-9fANNkzCZJ0i65FXGnoeg/knDPC3riazCDyRrcH/2DVovxChAMSN2mqh/7eohJ8IrB/0e6cwLO4VirqanSk1Hw==" + "integrity": "sha512-9fANNkzCZJ0i65FXGnoeg/knDPC3riazCDyRrcH/2DVovxChAMSN2mqh/7eohJ8IrB/0e6cwLO4VirqanSk1Hw==", + "requires": {} }, "braces": { "version": "3.0.2", @@ -38011,7 +38025,8 @@ "ws": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.0.tgz", - "integrity": "sha512-6ezXvzOZupqKj4jUqbQ9tXuJNo+BR2gU8fFRk3XCP3e0G6WT414u5ELe6Y0vtp7kmSJ3F7YWObSNr1ESsgi4vw==" + "integrity": "sha512-6ezXvzOZupqKj4jUqbQ9tXuJNo+BR2gU8fFRk3XCP3e0G6WT414u5ELe6Y0vtp7kmSJ3F7YWObSNr1ESsgi4vw==", + "requires": {} } } }, @@ -38471,12 +38486,14 @@ "graphql-type-json": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/graphql-type-json/-/graphql-type-json-0.3.2.tgz", - "integrity": "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==" + "integrity": "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==", + "requires": {} }, "graphql-ws": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-4.9.0.tgz", - "integrity": "sha512-sHkK9+lUm20/BGawNEWNtVAeJzhZeBg21VmvmLoT5NdGVeZWv5PdIhkcayQIAgjSyyQ17WMKmbDijIPG2On+Ag==" + "integrity": "sha512-sHkK9+lUm20/BGawNEWNtVAeJzhZeBg21VmvmLoT5NdGVeZWv5PdIhkcayQIAgjSyyQ17WMKmbDijIPG2On+Ag==", + "requires": {} }, "gray-matter": { "version": "4.0.3", @@ -39000,7 +39017,8 @@ "icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==" + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "requires": {} }, "ieee754": { "version": "1.2.1", @@ -39746,7 +39764,8 @@ "isomorphic-ws": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", - "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==" + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "requires": {} }, "isstream": { "version": "0.1.2", @@ -40171,7 +40190,8 @@ "jest-pnp-resolver": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", - "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==" + "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", + "requires": {} }, "jest-regex-util": { "version": "24.9.0", @@ -41238,7 +41258,8 @@ "meros": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/meros/-/meros-1.1.4.tgz", - "integrity": "sha512-E9ZXfK9iQfG9s73ars9qvvvbSIkJZF5yOo9j4tcwM5tN8mUKfj/EKN5PzOr3ZH0y5wL7dLAHw3RVEfpQV9Q7VQ==" + "integrity": "sha512-E9ZXfK9iQfG9s73ars9qvvvbSIkJZF5yOo9j4tcwM5tN8mUKfj/EKN5PzOr3ZH0y5wL7dLAHw3RVEfpQV9Q7VQ==", + "requires": {} }, "methods": { "version": "1.1.2", @@ -42551,27 +42572,32 @@ "postcss-discard-comments": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.0.1.tgz", - "integrity": "sha512-lgZBPTDvWrbAYY1v5GYEv8fEO/WhKOu/hmZqmCYfrpD6eyDWWzAOsl2rF29lpvziKO02Gc5GJQtlpkTmakwOWg==" + "integrity": "sha512-lgZBPTDvWrbAYY1v5GYEv8fEO/WhKOu/hmZqmCYfrpD6eyDWWzAOsl2rF29lpvziKO02Gc5GJQtlpkTmakwOWg==", + "requires": {} }, "postcss-discard-duplicates": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.0.1.tgz", - "integrity": "sha512-svx747PWHKOGpAXXQkCc4k/DsWo+6bc5LsVrAsw+OU+Ibi7klFZCyX54gjYzX4TH+f2uzXjRviLARxkMurA2bA==" + "integrity": "sha512-svx747PWHKOGpAXXQkCc4k/DsWo+6bc5LsVrAsw+OU+Ibi7klFZCyX54gjYzX4TH+f2uzXjRviLARxkMurA2bA==", + "requires": {} }, "postcss-discard-empty": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.0.1.tgz", - "integrity": "sha512-vfU8CxAQ6YpMxV2SvMcMIyF2LX1ZzWpy0lqHDsOdaKKLQVQGVP1pzhrI9JlsO65s66uQTfkQBKBD/A5gp9STFw==" + "integrity": "sha512-vfU8CxAQ6YpMxV2SvMcMIyF2LX1ZzWpy0lqHDsOdaKKLQVQGVP1pzhrI9JlsO65s66uQTfkQBKBD/A5gp9STFw==", + "requires": {} }, "postcss-discard-overridden": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.0.1.tgz", - "integrity": "sha512-Y28H7y93L2BpJhrdUR2SR2fnSsT+3TVx1NmVQLbcnZWwIUpJ7mfcTC6Za9M2PG6w8j7UQRfzxqn8jU2VwFxo3Q==" + "integrity": "sha512-Y28H7y93L2BpJhrdUR2SR2fnSsT+3TVx1NmVQLbcnZWwIUpJ7mfcTC6Za9M2PG6w8j7UQRfzxqn8jU2VwFxo3Q==", + "requires": {} }, "postcss-flexbugs-fixes": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", - "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==" + "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", + "requires": {} }, "postcss-loader": { "version": "5.3.0", @@ -42697,7 +42723,8 @@ "postcss-modules-extract-imports": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", - "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==" + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "requires": {} }, "postcss-modules-local-by-default": { "version": "4.0.0", @@ -42735,7 +42762,8 @@ "postcss-normalize-charset": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.0.1.tgz", - "integrity": "sha512-6J40l6LNYnBdPSk+BHZ8SF+HAkS4q2twe5jnocgd+xWpz/mx/5Sa32m3W1AA8uE8XaXN+eg8trIlfu8V9x61eg==" + "integrity": "sha512-6J40l6LNYnBdPSk+BHZ8SF+HAkS4q2twe5jnocgd+xWpz/mx/5Sa32m3W1AA8uE8XaXN+eg8trIlfu8V9x61eg==", + "requires": {} }, "postcss-normalize-display-values": { "version": "5.0.1", @@ -43106,7 +43134,8 @@ "prism-react-renderer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-1.2.1.tgz", - "integrity": "sha512-w23ch4f75V1Tnz8DajsYKvY5lF7H1+WvzvLUcF0paFxkTHSp42RS0H5CttdN2Q8RR3DRGZ9v5xD/h3n8C8kGmg==" + "integrity": "sha512-w23ch4f75V1Tnz8DajsYKvY5lF7H1+WvzvLUcF0paFxkTHSp42RS0H5CttdN2Q8RR3DRGZ9v5xD/h3n8C8kGmg==", + "requires": {} }, "prismjs": { "version": "1.28.0", @@ -43491,7 +43520,8 @@ "react-docgen-typescript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.0.0.tgz", - "integrity": "sha512-lPf+KJKAo6a9klKyK4y8WwgaX+6t5/HkVjHOpJDMbmaXfXcV7zP0QgWtnEOc3ccEUXKvlHMGUMIS9f6Zgo1BSw==" + "integrity": "sha512-lPf+KJKAo6a9klKyK4y8WwgaX+6t5/HkVjHOpJDMbmaXfXcV7zP0QgWtnEOc3ccEUXKvlHMGUMIS9f6Zgo1BSw==", + "requires": {} }, "react-dom": { "version": "17.0.1", @@ -43572,7 +43602,8 @@ "react-frame-component": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/react-frame-component/-/react-frame-component-5.2.3.tgz", - "integrity": "sha512-r+h0o3r/uqOLNT724z4CRVkxQouKJvoi3OPfjqWACD30Y87rtEmeJrNZf1WYPGknn1Y8200HAjx7hY/dPUGgmA==" + "integrity": "sha512-r+h0o3r/uqOLNT724z4CRVkxQouKJvoi3OPfjqWACD30Y87rtEmeJrNZf1WYPGknn1Y8200HAjx7hY/dPUGgmA==", + "requires": {} }, "react-helmet": { "version": "6.1.0", @@ -43669,12 +43700,14 @@ "react-side-effect": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.1.tgz", - "integrity": "sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ==" + "integrity": "sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ==", + "requires": {} }, "react-simple-code-editor": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.11.0.tgz", - "integrity": "sha512-xGfX7wAzspl113ocfKQAR8lWPhavGWHL3xSzNLeseDRHysT+jzRBi/ExdUqevSMos+7ZtdfeuBOXtgk9HTwsrw==" + "integrity": "sha512-xGfX7wAzspl113ocfKQAR8lWPhavGWHL3xSzNLeseDRHysT+jzRBi/ExdUqevSMos+7ZtdfeuBOXtgk9HTwsrw==", + "requires": {} }, "react-style-singleton": { "version": "2.1.1", @@ -45828,7 +45861,8 @@ "stylis-rule-sheet": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz", - "integrity": "sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw==" + "integrity": "sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw==", + "requires": {} }, "subscriptions-transport-ws": { "version": "0.9.19", @@ -47053,7 +47087,8 @@ "use-callback-ref": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.5.tgz", - "integrity": "sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==" + "integrity": "sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==", + "requires": {} }, "use-sidecar": { "version": "1.0.5", diff --git a/package-lock.json b/package-lock.json index f1c1012e40d..5c636850e1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "35.2.2", "license": "MIT", "dependencies": { + "@github/combobox-nav": "2.0.2", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", "@primer/primitives": "^7.6.0", @@ -3100,6 +3101,11 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, + "node_modules/@github/combobox-nav": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.0.2.tgz", + "integrity": "sha512-xVnncEyRjIFKWT1Bw0R51/V/13vwYrqg6v7rc8HNfsa5pstVqHx/L2ai8eX/3iK98uk6JxGJDzm8ryTo86S+nQ==" + }, "node_modules/@github/prettier-config": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@github/prettier-config/-/prettier-config-0.0.4.tgz", @@ -37148,6 +37154,11 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, + "@github/combobox-nav": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.0.2.tgz", + "integrity": "sha512-xVnncEyRjIFKWT1Bw0R51/V/13vwYrqg6v7rc8HNfsa5pstVqHx/L2ai8eX/3iK98uk6JxGJDzm8ryTo86S+nQ==" + }, "@github/prettier-config": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@github/prettier-config/-/prettier-config-0.0.4.tgz", diff --git a/package.json b/package.json index 529ac237845..d612261e91f 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "npm": ">=7" }, "dependencies": { + "@github/combobox-nav": "2.0.2", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", "@primer/primitives": "^7.6.0", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a6dd3f13016..89c4664ece6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -12,3 +12,5 @@ export {useRenderForcingRef} from './useRenderForcingRef' export {useProvidedStateOrCreate} from './useProvidedStateOrCreate' export {useMenuInitialFocus} from './useMenuInitialFocus' export {useTypeaheadFocus} from './useTypeaheadFocus' +export type {ComboboxCommitEvent} from './useCombobox' +export {useCombobox} from './useCombobox' diff --git a/src/hooks/useCombobox.ts b/src/hooks/useCombobox.ts new file mode 100644 index 00000000000..86e9fb186db --- /dev/null +++ b/src/hooks/useCombobox.ts @@ -0,0 +1,148 @@ +import Combobox from '@github/combobox-nav' +import {useSSRSafeId} from '@react-aria/ssr' +import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react' + +export interface ComboboxCommitEvent { + /** The underlying `combobox-commit` event. */ + nativeEvent: Event & {target: HTMLElement} + /** The option that was committed. */ + option: T +} + +interface UseComboboxSettings { + /** When open, the combobox will start listening for keyboard events. */ + isOpen: boolean + /** + * The list used to select items. This should usually be a Primer `ActionList`. The + * list must contain items with `role="option"`. + */ + listElement: HTMLOListElement | HTMLUListElement | null + /** + * The input this belongs to. The input value is not controlled by this component, but + * the element reference is used to bind keyboard events and attributes. + */ + inputElement: HTMLInputElement | HTMLTextAreaElement | null + /** Called when the user applies the selected suggestion. */ + onCommit: (event: ComboboxCommitEvent) => void + /** + * The array of available options. `useCombobox` doesn't render the options, but it does + * need to know what they are (for callbacks) and when they change (for binding events + * and attributes). + */ + options: Array +} + +/** + * Lightweight hook wrapper around the GitHub `Combobox` class from `@github/combobox-nav`. + * With this hook, keyboard navigation through suggestions is automatically handled and + * accessibility attributes are added. + * + * `useCombobox` will set nearly all necessary attributes by effect, but you **must** set + * `role="option"` on list items in order for them to be 'seen' by the combobox. Style the + * currently highlighted option with the `[aria-selected="true"]` selector. + */ +export const useCombobox = ({ + isOpen, + listElement: list, + inputElement: input, + onCommit: externalOnCommit, + options +}: UseComboboxSettings) => { + const id = useSSRSafeId() + const optionIdPrefix = `combobox-${id}__option` + + const isOpenRef = useRef(isOpen) + + const [comboboxInstance, setComboboxInstance] = useState(null) + + /** Get all option element instances. */ + const getOptionElements = useCallback( + () => [...(list?.querySelectorAll('[role=option]') ?? [])] as Array, + [list] + ) + + const selectFirstItem = useCallback(() => { + if (list) { + const optionElements = getOptionElements() + optionElements.shift()?.setAttribute('aria-selected', 'true') + for (const el of optionElements) el.setAttribute('aria-selected', 'false') + } + }, [list, getOptionElements]) + + const onCommit = useCallback( + (e: Event) => { + const nativeEvent = e as Event & {target: HTMLElement} + const indexAttr = nativeEvent.target.getAttribute('data-combobox-list-index') + const index = indexAttr !== null ? parseInt(indexAttr, 10) : NaN + const option = options[index] + if (option) externalOnCommit({nativeEvent, option}) + }, + [options, externalOnCommit] + ) + + // Prevent focus leaving the input when clicking an item + const onOptionMouseDown = useCallback((e: MouseEvent) => e.preventDefault(), []) + + useEffect( + function initializeComboboxInstance() { + if (input && list) { + // The Combobox constructor sets the input role but not the list role + if (!list.getAttribute('role')) list.setAttribute('role', 'listbox') + + const cb = new Combobox(input, list) + if (isOpenRef.current) cb.start() + + // By using state instead of a ref here, we trigger the toggleKeyboardEventHandling + // effect. Otherwise we'd have to depend on isOpen in this effect to start the instance + // if it's initially open + setComboboxInstance(cb) + + return () => { + cb.destroy() + setComboboxInstance(null) + } + } + }, + [input, list] + ) + + useEffect( + function toggleKeyboardEventHandling() { + const wasOpen = isOpenRef.current + isOpenRef.current = isOpen + + if (isOpen === wasOpen || !comboboxInstance) return + + if (isOpen) { + comboboxInstance.start() + } else { + comboboxInstance.stop() + } + }, + [isOpen, comboboxInstance, selectFirstItem] + ) + + useEffect( + function bindCommitEvent() { + list?.addEventListener('combobox-commit', onCommit) + return () => list?.removeEventListener('combobox-commit', onCommit) + }, + [onCommit, list] + ) + + useLayoutEffect(() => { + const optionElements = getOptionElements() + // Ensure each option has a unique ID (required by the Combobox class), but respect user provided IDs + for (const [i, option] of optionElements.entries()) { + if (!option.id || option.id.startsWith(optionIdPrefix)) option.id = `${optionIdPrefix}-${i}` + option.setAttribute('data-combobox-list-index', i.toString()) + option.addEventListener('mousedown', onOptionMouseDown) + } + + selectFirstItem() + + return () => { + for (const option of optionElements) option.removeEventListener('mousedown', onOptionMouseDown) + } + }, [getOptionElements, optionIdPrefix, options, selectFirstItem, onOptionMouseDown]) +} From 9ef18e3a7ec7f27136d425297aca8b7b23be7602 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 14 Jun 2022 18:27:01 +0000 Subject: [PATCH 002/119] Add `useSyntheticChange` hook --- src/hooks/useSyntheticChange.ts | 149 ++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/hooks/useSyntheticChange.ts diff --git a/src/hooks/useSyntheticChange.ts b/src/hooks/useSyntheticChange.ts new file mode 100644 index 00000000000..0cd18d3ca71 --- /dev/null +++ b/src/hooks/useSyntheticChange.ts @@ -0,0 +1,149 @@ +import {useCallback} from 'react' + +const calculateNewCaretPosition = ( + originalCaretPosition: number, + replaceRange: [number, number], + insertLength: number +): number => { + const deleteLength = replaceRange[1] - replaceRange[0] + const lengthDifference = insertLength - deleteLength + + // If caret is before the replacement, position is unaffected. If it is at/in the replacement + // section, move it to the end (as though the user had selected text and typed + // the replacement). If it is after the replacement, move it by the length difference. + return originalCaretPosition < replaceRange[0] + ? originalCaretPosition + : originalCaretPosition < replaceRange[1] + ? replaceRange[0] + insertLength + : originalCaretPosition + lengthDifference +} + +/** + * Builds a fake `React.ChangeEvent` from a dispatched `InputEvent` instance. + * This is only used as a fallback in cases where browsers don't support `execCommand`. + */ +const SyntheticChangeEvent = ( + dispatchedEvent: InputEvent, + // Could use dispatchedEvent.target, but that would require a type assertion because InputEvent is not generic + target: Element +): React.ChangeEvent => ({ + // Spreading the event is particularly imperfect. Functions called on the `SyntheticEvent` + // will have the wrong `this` binding and shallow object properties may fall out of sync. + // We consider this acceptable since this is only the fallback behavior, but it's not ideal by any means. + ...dispatchedEvent, + nativeEvent: dispatchedEvent, + target, + // `currentTarget` is the element that the event listener is attached to. The event + // doesn't know this, so `event.currentTarget` is `null`. + currentTarget: target, + preventDefault: () => dispatchedEvent.preventDefault(), + isDefaultPrevented: () => dispatchedEvent.defaultPrevented, + // This event doesn't bubble anyway so there's no need for the consumer to try to + // stop propagation + isPropagationStopped: () => false, + // "As of v17, e.persist() doesnโ€™t do anything because the SyntheticEvent is no + // longer pooled" - https://reactjs.org/docs/events.html#overview + persist: () => ({ + /* noop */ + }) +}) + +interface UseSyntheticChangeSettings< + Element extends HTMLTextAreaElement | HTMLInputElement = HTMLTextAreaElement | HTMLInputElement +> { + /** Ref to the input element to change. */ + inputRef: React.RefObject + /** + * A callback that will be triggered when the normal method of faking a synthetic event + * fails. This should be the same function as the input's `onChange` handler. + * + * The ideal behavior is to simulate change as though a user had typed the value, which in + * turn will call any change event handlers on the input. That doesn't work in all browsers, + * so the fallback behavior is to call this handler with a simulated event. + */ + fallbackEventHandler: React.ChangeEventHandler +} + +/** + * A function that, when called, will simulate a synthetic change event on the bound input. + * @param insertValue The value to insert. + * @param replaceRange The range of text to replace. By default, text will be inserted + * as though the user typed it, replacing any currently selected text. + * @param newSelection Selection to apply after the change. By default, the caret will + * be automatically adjusted based on the replaced text, moving it to the end of the inserted + * text if it was inside the `replaceRange` before. Can be a single number for a caret location + * or two numbers for a selection range. + */ +type SyntheticChangeEmitter = ( + insertValue: string, + replaceRange?: [startIndexInclusive: number, endIndexExclusive: number], + newSelection?: number | [number, number] +) => void + +/** + * Returns a function that will synthetically change the input, attempting to maintain caret + * position and undo history as though the user had typed using a keyboard. + * + * Will first attempt to use the non-standard browser `execCommmand` API to simulate a typing + * action. Failing this (ie, in test environments or certain browsers), the fallback handler + * will be called with a fake constructed `ChangeEvent` that looks like a real event. + */ +export const useSyntheticChange = ({inputRef, fallbackEventHandler}: UseSyntheticChangeSettings) => + useCallback( + (insertValue, replaceRange_, newSelection_) => { + const input = inputRef.current + if (!input) return + + const replaceRange = replaceRange_ ?? [ + input.selectionStart ?? input.value.length, + input.selectionEnd ?? input.value.length + ] + + const newSelectionStart = + newSelection_ === undefined + ? calculateNewCaretPosition(input.selectionStart ?? input.value.length, replaceRange, insertValue.length) + : Array.isArray(newSelection_) + ? newSelection_[0] + : newSelection_ + const newSelectionEnd = Array.isArray(newSelection_) ? newSelection_[1] : newSelectionStart + + // execCommmand simulates the user actually typing the value into the input. This preserves the undo history, + // but it's a deprecated API and there's no alternative. It also doesn't work in test environments + let execCommandResult = false + try { + // expand selection to the whole range and replace it with the new value + input.setSelectionRange(replaceRange[0], replaceRange[1]) + execCommandResult = + insertValue === '' + ? document.execCommand('delete', false) + : document.execCommand('insertText', false, insertValue) + input.setSelectionRange(newSelectionStart, newSelectionEnd) + } catch (e) { + execCommandResult = false + } + + // If the execCommand method failed, call onChange instead - will nuke the undo history :( + if (!execCommandResult) { + const newValue = input.value.slice(0, replaceRange[0]) + insertValue + input.value.slice(replaceRange[1]) + + // When building the event we could also define the inputType and data, but that would + // be complex for the consumer to maintain. For now that's not functionality that is + // strictly necessary. + // React SyntheticChangeEvents are actually built around 'input' events, not 'change' events + const event = new InputEvent('input', {bubbles: false}) + inputRef.current.value = newValue + inputRef.current.setSelectionRange(newSelectionStart, newSelectionEnd) + + // Even though we call onChange manually, we must dispatch the event so the browser can + // set its `target` and fully create it + inputRef.current.dispatchEvent(event) + + // Surprisingly, dispatching the event does not cause React to call handlers, even + // though it looks almost exactly like a normal 'input' event. Maybe it's because the + // event is not trusted? So we have to build and dispatch the `SyntheticEvent` ourselves. + // This is not perfect but it gets pretty close. + fallbackEventHandler(SyntheticChangeEvent(event, inputRef.current)) + } + }, + [inputRef, fallbackEventHandler] + ) From d14418e6e1bb9f7fe061cfd94653736e3eae2a54 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 14 Jun 2022 18:55:53 +0000 Subject: [PATCH 003/119] Add `InlineAutocomplete` component --- package-lock.json | 11 ++ package.json | 1 + src/InlineAutocomplete/InlineAutocomplete.tsx | 172 ++++++++++++++++++ .../_AutocompleteSuggestions.tsx | 108 +++++++++++ src/InlineAutocomplete/index.ts | 6 + src/InlineAutocomplete/types.ts | 64 +++++++ src/InlineAutocomplete/utils.ts | 148 +++++++++++++++ src/utils/types/textarea-caret.d.ts | 11 ++ 8 files changed, 521 insertions(+) create mode 100644 src/InlineAutocomplete/InlineAutocomplete.tsx create mode 100644 src/InlineAutocomplete/_AutocompleteSuggestions.tsx create mode 100644 src/InlineAutocomplete/index.ts create mode 100644 src/InlineAutocomplete/types.ts create mode 100644 src/InlineAutocomplete/utils.ts create mode 100644 src/utils/types/textarea-caret.d.ts diff --git a/package-lock.json b/package-lock.json index 5c636850e1e..01eb47dafed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@github/combobox-nav": "2.0.2", + "@koddsson/textarea-caret": "4.0.1", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", "@primer/primitives": "^7.6.0", @@ -5256,6 +5257,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@koddsson/textarea-caret": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@koddsson/textarea-caret/-/textarea-caret-4.0.1.tgz", + "integrity": "sha512-KaHkM8WX2VCNcCzg7Q83aBcWhpCTkC/olARZbvSbQtAQPK+zXutLBhNNtpPgGL6ELXlA27tiD+kMfWyDLs3n+Q==" + }, "node_modules/@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", @@ -38805,6 +38811,11 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "@koddsson/textarea-caret": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@koddsson/textarea-caret/-/textarea-caret-4.0.1.tgz", + "integrity": "sha512-KaHkM8WX2VCNcCzg7Q83aBcWhpCTkC/olARZbvSbQtAQPK+zXutLBhNNtpPgGL6ELXlA27tiD+kMfWyDLs3n+Q==" + }, "@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", diff --git a/package.json b/package.json index d612261e91f..11ba3d1d521 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ }, "dependencies": { "@github/combobox-nav": "2.0.2", + "@koddsson/textarea-caret": "4.0.1", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", "@primer/primitives": "^7.6.0", diff --git a/src/InlineAutocomplete/InlineAutocomplete.tsx b/src/InlineAutocomplete/InlineAutocomplete.tsx new file mode 100644 index 00000000000..ef016b3b961 --- /dev/null +++ b/src/InlineAutocomplete/InlineAutocomplete.tsx @@ -0,0 +1,172 @@ +import React, {cloneElement, useRef} from 'react' +import Box from '../Box' +import {useCombinedRefs} from '../hooks/useCombinedRefs' +import {useSyntheticChange} from '../hooks/useSyntheticChange' +import {BetterSystemStyleObject} from '../sx' + +import {ShowSuggestionsEvent, Suggestions, TextInputCompatibleChild, TextInputElement, Trigger} from './types' +import { + augmentHandler, + calculateSuggestionsQuery, + getAbsoluteCharacterCoordinates, + requireChildrenToBeInput +} from './utils' +import AutocompleteSuggestions from './_AutocompleteSuggestions' + +export interface InlineAutocompleteProps { + /** Register the triggers that can cause suggestions to appear. */ + triggers: Array + /** + * Called when a valid suggestion query is updated. This should be handled by setting the + * `suggestions` prop accordingly. + */ + onShowSuggestions: (event: ShowSuggestionsEvent) => void + /** Called when suggestions should be hidden. Set `suggestions` to `null` in this case. */ + onHideSuggestions: () => void + /** + * The currently visible list of suggestions. If `loading`, a loading indicator will be + * shown. If `null` or empty, the list will be hidden. Suggestion sort will be preserved. + * + * Typically, this should not contain more than five or so suggestions. + */ + suggestions: Suggestions | null + /** + * The `AutocompleteTextarea` has a container for positioning the suggestions overlay. + * This can break some layouts (ie, if the editor must expand with `flex: 1` to fill space) + * so you can override container styles here. Usually this should not be necessary. + * `position` may not be overriden. + */ + sx?: Omit + // Typing this as such makes it look like a compatible child internally, but it isn't actually + // enforced externally so we have to resort to a runtime assertion. + children: TextInputCompatibleChild +} + +const getSelectionStart = (element: TextInputElement) => { + try { + return element.selectionStart + } catch (e: unknown) { + // Safari throws an exception when trying to access selectionStart on date input element + if (e instanceof TypeError) return null + throw e + } +} + +const noop = () => { + /* noop */ +} + +/** + * Shows suggestions to complete the current word/phrase the user is actively typing. + * This is different from your standard combobox because the pattern is not 'select an item + * from a list', it's more 'suggest typing hints'. + * + * This component accepts a single child that has props compatible with either + * `` or `