diff --git a/.changeset/cuddly-bags-sort.md b/.changeset/cuddly-bags-sort.md
new file mode 100644
index 00000000000..d9ae0c3c250
--- /dev/null
+++ b/.changeset/cuddly-bags-sort.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": patch
+---
+
+Add `InlineAutocomplete` component, `useCombobox` hook, and `useSyntheticChange` hook to drafts
diff --git a/.changeset/funny-roses-impress.md b/.changeset/funny-roses-impress.md
new file mode 100644
index 00000000000..16e99ea1919
--- /dev/null
+++ b/.changeset/funny-roses-impress.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": minor
+---
+
+Add `padding` prop to `PageLayout.Header`, `PageLayout.Content`, `PageLayout.Pane`, and `PageLayout.Footer`
diff --git a/@types/@koddsson/index.d.ts b/@types/@koddsson/index.d.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/@types/@koddsson/textarea-caret/index.d.ts b/@types/@koddsson/textarea-caret/index.d.ts
new file mode 100644
index 00000000000..c2505997701
--- /dev/null
+++ b/@types/@koddsson/textarea-caret/index.d.ts
@@ -0,0 +1,11 @@
+declare module '@koddsson/textarea-caret' {
+ export interface CaretCoordinates {
+ top: number
+ left: number
+ height: number
+ }
+ export default function getCaretCoordinates(
+ input: HTMLTextAreaElement | HTMLInputElement,
+ index: number
+ ): CaretCoordinates
+}
diff --git a/docs/content/PageLayout.mdx b/docs/content/PageLayout.mdx
index c4f939df260..577016ad206 100644
--- a/docs/content/PageLayout.mdx
+++ b/docs/content/PageLayout.mdx
@@ -58,6 +58,25 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
```
+### With connected dividers
+
+```jsx live
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
### With pane on left
```jsx live
@@ -139,29 +158,29 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
| 'medium'
| 'large'
| 'xlarge'`}
- defaultValue="'full'"
+ defaultValue="'xlarge'"
description="The maximum width of the page container."
/>
@@ -170,6 +189,14 @@ See [storybook](https://primer.style/react/storybook?path=/story/layout-pagelayo
### PageLayout.Header
+
+
+
+
+
+Input components **must always** be accompanied by a corresponding label to improve support for assistive
+technologies. Examples below are provided for conciseness and may not reflect accessibility best practices.
+
+`InlineAutocomplete` can be used with the [`FormControl`](/FormControl) component to render a corresponding label.
+
+
+
+### Multi-line input
+
+Try typing a `#` symbol to see suggestions. Use `Enter` or click to apply a suggestion.
+
+```javascript live noinline drafts
+const options = ['javascript', 'typescript', 'css', 'html', 'webassembly']
+
+const SimpleExample = () => {
+ const [suggestions, setSuggestions] = React.useState([])
+
+ return (
+ setSuggestions(options.filter(tag => tag.includes(query)))}
+ onHideSuggestions={() => setSuggestions([])}
+ >
+
+
+ )
+}
+
+render(SimpleExample)
+```
+
+### Single-line input
+
+```javascript live noinline drafts
+const options = ['javascript', 'typescript', 'css', 'html', 'webassembly']
+
+const SimpleExample = () => {
+ const [suggestions, setSuggestions] = React.useState([])
+
+ return (
+ setSuggestions(options.filter(tag => tag.includes(query)))}
+ onHideSuggestions={() => setSuggestions([])}
+ >
+
+
+ )
+}
+
+render(SimpleExample)
+```
+
+### Labelled
+
+```javascript live noinline drafts
+const options = ['javascript', 'typescript', 'css', 'html', 'webassembly']
+
+const SimpleExample = () => {
+ const [suggestions, setSuggestions] = React.useState([])
+
+ return (
+
+ Example
+ setSuggestions(options.filter(tag => tag.includes(query)))}
+ onHideSuggestions={() => setSuggestions([])}
+ >
+
+
+
+ )
+}
+
+render(SimpleExample)
+```
+
+## Props
+
+
+
+
+
+
+
+
+
+
+
+## Status
+
+
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 1dfd0931612..222db9c2f47 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -28720,8 +28720,7 @@
"ws": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz",
- "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==",
- "requires": {}
+ "integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g=="
}
}
},
@@ -29079,8 +29078,7 @@
"@mdx-js/react": {
"version": "1.6.22",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz",
- "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==",
- "requires": {}
+ "integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg=="
},
"@mdx-js/util": {
"version": "1.6.22",
@@ -29295,8 +29293,7 @@
"@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==",
- "requires": {}
+ "integrity": "sha512-uzTs8/CvLiW1/47cgMRkIK9bKWpnw+UonCbgczXErwSSLqMDHfiiTpobW1trvRuoiMgLwsPo0l7kBBdKBnmq3g=="
},
"@primer/primitives": {
"version": "4.1.0",
@@ -29331,8 +29328,7 @@
"@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==",
- "requires": {}
+ "integrity": "sha512-xCxQ5z23ol7yDuJs85Lc4ARzyoay+b3zOhAKkEMU7chk0xi2hT2OnRP23QUudNNDPTGozX268RGYLexUa6P4xw=="
},
"@primer/primitives": {
"version": "7.6.0",
@@ -29344,8 +29340,7 @@
"@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==",
- "requires": {}
+ "integrity": "sha512-9nsMZEDU3LeIUeHJrpkkhZVxu/9Fc7P2g2I3WR+uA9mTbNC3hGaabi0dV6wg0CfHb+m4nSs1pejbE/5no3MJTA=="
},
"@react-aria/ssr": {
"version": "3.1.0",
@@ -30479,8 +30474,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng=="
},
"acorn-walk": {
"version": "6.2.0",
@@ -30515,14 +30509,12 @@
"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==",
- "requires": {}
+ "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ=="
},
"ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
- "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
- "requires": {}
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="
},
"alphanum-sort": {
"version": "1.0.2",
@@ -31049,8 +31041,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-PkHJuRodMp4p617a/ZVhV8elBhRoFpOTpdu2DaApXJFIsDJWhjZ8d4BGbbFCT/yKJrhRDTdqg1r5AhWEaEUKkw=="
},
"babel-plugin-styled-components": {
"version": "2.0.2",
@@ -33121,8 +33112,7 @@
"cssnano-utils": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-2.0.1.tgz",
- "integrity": "sha512-i8vLRZTnEH9ubIyfdZCAdIdgnHAUeQeByEeQ2I7oTilvP9oHO6RScpeq3GsFUVqeB8uZgOQ9pw8utofNn32hhQ==",
- "requires": {}
+ "integrity": "sha512-i8vLRZTnEH9ubIyfdZCAdIdgnHAUeQeByEeQ2I7oTilvP9oHO6RScpeq3GsFUVqeB8uZgOQ9pw8utofNn32hhQ=="
},
"csso": {
"version": "4.2.0",
@@ -33753,8 +33743,7 @@
"ws": {
"version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
- "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
- "requires": {}
+ "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A=="
}
}
},
@@ -33778,8 +33767,7 @@
"ws": {
"version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
- "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
- "requires": {}
+ "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A=="
}
}
},
@@ -34501,8 +34489,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ=="
},
"eslint-scope": {
"version": "5.1.1",
@@ -36283,8 +36270,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-9fANNkzCZJ0i65FXGnoeg/knDPC3riazCDyRrcH/2DVovxChAMSN2mqh/7eohJ8IrB/0e6cwLO4VirqanSk1Hw=="
},
"braces": {
"version": "3.0.2",
@@ -38025,8 +38011,7 @@
"ws": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.0.tgz",
- "integrity": "sha512-6ezXvzOZupqKj4jUqbQ9tXuJNo+BR2gU8fFRk3XCP3e0G6WT414u5ELe6Y0vtp7kmSJ3F7YWObSNr1ESsgi4vw==",
- "requires": {}
+ "integrity": "sha512-6ezXvzOZupqKj4jUqbQ9tXuJNo+BR2gU8fFRk3XCP3e0G6WT414u5ELe6Y0vtp7kmSJ3F7YWObSNr1ESsgi4vw=="
}
}
},
@@ -38486,14 +38471,12 @@
"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==",
- "requires": {}
+ "integrity": "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg=="
},
"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==",
- "requires": {}
+ "integrity": "sha512-sHkK9+lUm20/BGawNEWNtVAeJzhZeBg21VmvmLoT5NdGVeZWv5PdIhkcayQIAgjSyyQ17WMKmbDijIPG2On+Ag=="
},
"gray-matter": {
"version": "4.0.3",
@@ -39017,8 +39000,7 @@
"icss-utils": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
- "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
- "requires": {}
+ "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="
},
"ieee754": {
"version": "1.2.1",
@@ -39764,8 +39746,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w=="
},
"isstream": {
"version": "0.1.2",
@@ -40190,8 +40171,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w=="
},
"jest-regex-util": {
"version": "24.9.0",
@@ -41258,8 +41238,7 @@
"meros": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/meros/-/meros-1.1.4.tgz",
- "integrity": "sha512-E9ZXfK9iQfG9s73ars9qvvvbSIkJZF5yOo9j4tcwM5tN8mUKfj/EKN5PzOr3ZH0y5wL7dLAHw3RVEfpQV9Q7VQ==",
- "requires": {}
+ "integrity": "sha512-E9ZXfK9iQfG9s73ars9qvvvbSIkJZF5yOo9j4tcwM5tN8mUKfj/EKN5PzOr3ZH0y5wL7dLAHw3RVEfpQV9Q7VQ=="
},
"methods": {
"version": "1.1.2",
@@ -42572,32 +42551,27 @@
"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==",
- "requires": {}
+ "integrity": "sha512-lgZBPTDvWrbAYY1v5GYEv8fEO/WhKOu/hmZqmCYfrpD6eyDWWzAOsl2rF29lpvziKO02Gc5GJQtlpkTmakwOWg=="
},
"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==",
- "requires": {}
+ "integrity": "sha512-svx747PWHKOGpAXXQkCc4k/DsWo+6bc5LsVrAsw+OU+Ibi7klFZCyX54gjYzX4TH+f2uzXjRviLARxkMurA2bA=="
},
"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==",
- "requires": {}
+ "integrity": "sha512-vfU8CxAQ6YpMxV2SvMcMIyF2LX1ZzWpy0lqHDsOdaKKLQVQGVP1pzhrI9JlsO65s66uQTfkQBKBD/A5gp9STFw=="
},
"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==",
- "requires": {}
+ "integrity": "sha512-Y28H7y93L2BpJhrdUR2SR2fnSsT+3TVx1NmVQLbcnZWwIUpJ7mfcTC6Za9M2PG6w8j7UQRfzxqn8jU2VwFxo3Q=="
},
"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==",
- "requires": {}
+ "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ=="
},
"postcss-loader": {
"version": "5.3.0",
@@ -42723,8 +42697,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw=="
},
"postcss-modules-local-by-default": {
"version": "4.0.0",
@@ -42762,8 +42735,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-6J40l6LNYnBdPSk+BHZ8SF+HAkS4q2twe5jnocgd+xWpz/mx/5Sa32m3W1AA8uE8XaXN+eg8trIlfu8V9x61eg=="
},
"postcss-normalize-display-values": {
"version": "5.0.1",
@@ -43134,8 +43106,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-w23ch4f75V1Tnz8DajsYKvY5lF7H1+WvzvLUcF0paFxkTHSp42RS0H5CttdN2Q8RR3DRGZ9v5xD/h3n8C8kGmg=="
},
"prismjs": {
"version": "1.28.0",
@@ -43520,8 +43491,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-lPf+KJKAo6a9klKyK4y8WwgaX+6t5/HkVjHOpJDMbmaXfXcV7zP0QgWtnEOc3ccEUXKvlHMGUMIS9f6Zgo1BSw=="
},
"react-dom": {
"version": "17.0.1",
@@ -43602,8 +43572,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-r+h0o3r/uqOLNT724z4CRVkxQouKJvoi3OPfjqWACD30Y87rtEmeJrNZf1WYPGknn1Y8200HAjx7hY/dPUGgmA=="
},
"react-helmet": {
"version": "6.1.0",
@@ -43700,14 +43669,12 @@
"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==",
- "requires": {}
+ "integrity": "sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ=="
},
"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==",
- "requires": {}
+ "integrity": "sha512-xGfX7wAzspl113ocfKQAR8lWPhavGWHL3xSzNLeseDRHysT+jzRBi/ExdUqevSMos+7ZtdfeuBOXtgk9HTwsrw=="
},
"react-style-singleton": {
"version": "2.1.1",
@@ -45861,8 +45828,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw=="
},
"subscriptions-transport-ws": {
"version": "0.9.19",
@@ -47087,8 +47053,7 @@
"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==",
- "requires": {}
+ "integrity": "sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg=="
},
"use-sidecar": {
"version": "1.0.5",
diff --git a/docs/src/@primer/gatsby-theme-doctocat/nav.yml b/docs/src/@primer/gatsby-theme-doctocat/nav.yml
index 48faae7fe91..a5d0f6c2dbf 100644
--- a/docs/src/@primer/gatsby-theme-doctocat/nav.yml
+++ b/docs/src/@primer/gatsby-theme-doctocat/nav.yml
@@ -151,6 +151,8 @@
children:
- title: Dialog v2
url: /drafts/Dialog
+ - title: InlineAutocomplete
+ url: /drafts/InlineAutocomplete
- title: Deprecated
children:
- title: ActionList (legacy)
diff --git a/jest.config.js b/jest.config.js
index 6926a1ecac7..22eebe81b14 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -9,5 +9,6 @@ module.exports = {
'/src/utils/test-deprecations.tsx',
'/src/utils/test-helpers.tsx'
],
- testMatch: ['/(src|codemods)/**/*.test.[jt]s?(x)', '!**/*.types.test.[jt]s?(x)']
+ testMatch: ['/(src|codemods)/**/*.test.[jt]s?(x)', '!**/*.types.test.[jt]s?(x)'],
+ transformIgnorePatterns: ['node_modules/(?!@github/combobox-nav|@koddsson/textarea-caret)']
}
diff --git a/package-lock.json b/package-lock.json
index 803b58ecae6..bf45cc50173 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,8 @@
"version": "35.5.0",
"license": "MIT",
"dependencies": {
+ "@github/combobox-nav": "^2.1.5",
+ "@koddsson/textarea-caret": "^4.0.1",
"@primer/behaviors": "^1.1.1",
"@primer/octicons-react": "^17.3.0",
"@primer/primitives": "7.9.0",
@@ -3105,6 +3107,11 @@
"integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
"dev": true
},
+ "node_modules/@github/combobox-nav": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz",
+ "integrity": "sha512-dmG1PuppNKHnBBEcfylWDwj9SSxd/E/qd8mC1G/klQC3s7ps5q6JZ034mwkkG0LKfI+Y+UgEua/ROD776N400w=="
+ },
"node_modules/@github/prettier-config": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@github/prettier-config/-/prettier-config-0.0.4.tgz",
@@ -5315,6 +5322,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",
@@ -39538,6 +39550,11 @@
"integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
"dev": true
},
+ "@github/combobox-nav": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz",
+ "integrity": "sha512-dmG1PuppNKHnBBEcfylWDwj9SSxd/E/qd8mC1G/klQC3s7ps5q6JZ034mwkkG0LKfI+Y+UgEua/ROD776N400w=="
+ },
"@github/prettier-config": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@github/prettier-config/-/prettier-config-0.0.4.tgz",
@@ -41235,6 +41252,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 0c94d02cdf3..9c448abf8a6 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"build:docs": "script/build-docs",
"build:docs:preview": "script/build-docs preview",
"build:storybook:visual-testing": "build-storybook",
+ "install:docs": "(cd docs && npm install --legacy-peer-deps)",
"lint": "eslint '**/*.{js,ts,tsx,md,mdx}' --max-warnings=0",
"lint:fix": "npm run lint -- --fix",
"test": "jest",
@@ -79,6 +80,8 @@
"npm": ">=7"
},
"dependencies": {
+ "@github/combobox-nav": "^2.1.5",
+ "@koddsson/textarea-caret": "^4.0.1",
"@primer/behaviors": "^1.1.1",
"@primer/octicons-react": "^17.3.0",
"@primer/primitives": "7.9.0",
diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx
index 9ffd6be8ac7..0193ad47f95 100644
--- a/src/FormControl/FormControl.tsx
+++ b/src/FormControl/FormControl.tsx
@@ -9,6 +9,7 @@ import {get} from '../constants'
import FormControlLeadingVisual from './_FormControlLeadingVisual'
import {SxProp} from '../sx'
import CheckboxOrRadioGroupContext from '../_CheckboxOrRadioGroup/_CheckboxOrRadioGroupContext'
+import InlineAutocomplete from '../drafts/InlineAutocomplete'
export type FormControlProps = {
children?: React.ReactNode
@@ -38,7 +39,16 @@ export interface FormControlContext extends Pick(
({children, disabled: disabledProp, layout, id: idProp, required, sx}, ref) => {
- const expectedInputComponents = [Autocomplete, Checkbox, Radio, Select, TextInput, TextInputWithTokens, Textarea]
+ const expectedInputComponents = [
+ Autocomplete,
+ Checkbox,
+ Radio,
+ Select,
+ TextInput,
+ TextInputWithTokens,
+ Textarea,
+ InlineAutocomplete
+ ]
const choiceGroupContext = useContext(CheckboxOrRadioGroupContext)
const disabled = choiceGroupContext?.disabled || disabledProp
const id = useSSRSafeId(idProp)
diff --git a/src/PageLayout/PageLayout.tsx b/src/PageLayout/PageLayout.tsx
index 24a894d84ee..5825f0a0510 100644
--- a/src/PageLayout/PageLayout.tsx
+++ b/src/PageLayout/PageLayout.tsx
@@ -171,6 +171,7 @@ const VerticalDivider: React.FC> = ({varia
// PageLayout.Header
export type PageLayoutHeaderProps = {
+ padding?: keyof typeof SPACING_MAP
divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'>
/**
* @deprecated Use the `divider` prop with a responsive value instead.
@@ -191,6 +192,7 @@ export type PageLayoutHeaderProps = {
} & SxProp
const Header: React.FC> = ({
+ padding = 'none',
divider = 'none',
dividerWhenNarrow = 'inherit',
hidden = false,
@@ -219,7 +221,7 @@ const Header: React.FC> = ({
sx
)}
>
- {children}
+ {children}
)
@@ -232,6 +234,7 @@ Header.displayName = 'PageLayout.Header'
export type PageLayoutContentProps = {
width?: keyof typeof contentWidths
+ padding?: keyof typeof SPACING_MAP
hidden?: boolean | ResponsiveValue
} & SxProp
@@ -245,6 +248,7 @@ const contentWidths = {
const Content: React.FC> = ({
width = 'full',
+ padding = 'none',
hidden = false,
children,
sx = {}
@@ -268,7 +272,9 @@ const Content: React.FC> = ({
sx
)}
>
- {children}
+
+ {children}
+
)
}
@@ -296,6 +302,7 @@ export type PageLayoutPaneProps = {
*/
positionWhenNarrow?: 'inherit' | keyof typeof panePositions
width?: keyof typeof paneWidths
+ padding?: keyof typeof SPACING_MAP
divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'>
/**
* @deprecated Use the `divider` prop with a responsive value instead.
@@ -330,6 +337,7 @@ const Pane: React.FC> = ({
position = 'end',
positionWhenNarrow = 'inherit',
width = 'medium',
+ padding = 'none',
divider = 'none',
dividerWhenNarrow = 'inherit',
hidden = false,
@@ -388,7 +396,7 @@ const Pane: React.FC> = ({
sx={{[responsivePosition === 'end' ? 'marginRight' : 'marginLeft']: SPACING_MAP[columnGap]}}
/>
- {children}
+ {children}
)
}
@@ -399,6 +407,7 @@ Pane.displayName = 'PageLayout.Pane'
// PageLayout.Footer
export type PageLayoutFooterProps = {
+ padding?: keyof typeof SPACING_MAP
divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'>
/**
* @deprecated Use the `divider` prop with a responsive value instead.
@@ -419,6 +428,7 @@ export type PageLayoutFooterProps = {
} & SxProp
const Footer: React.FC> = ({
+ padding = 'none',
divider = 'none',
dividerWhenNarrow = 'inherit',
hidden = false,
@@ -448,7 +458,7 @@ const Footer: React.FC> = ({
)}
>
- {children}
+ {children}
)
}
diff --git a/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap b/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap
index cb10f8a8e57..5f9e4c680bb 100644
--- a/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap
+++ b/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap
@@ -14,7 +14,11 @@ exports[`PageLayout renders condensed layout 1`] = `
flex-wrap: wrap;
}
-.c4 {
+.c3 {
+ padding: 0;
+}
+
+.c5 {
-webkit-order: 2;
-ms-flex-order: 2;
order: 2;
@@ -31,15 +35,17 @@ exports[`PageLayout renders condensed layout 1`] = `
min-width: 1px;
}
-.c5 {
+.c6 {
width: 100%;
max-width: 100%;
margin-left: auto;
margin-right: auto;
+ padding: 0;
}
-.c9 {
+.c10 {
width: 100%;
+ padding: 0;
}
.c0 {
@@ -54,14 +60,14 @@ exports[`PageLayout renders condensed layout 1`] = `
margin-bottom: 16px;
}
-.c3 {
+.c4 {
margin-left: -16px;
margin-right: -16px;
display: none;
margin-top: 16px;
}
-.c6 {
+.c7 {
-webkit-order: 3;
-ms-flex-order: 3;
order: 3;
@@ -78,20 +84,20 @@ exports[`PageLayout renders condensed layout 1`] = `
margin-top: 16px;
}
-.c7 {
+.c8 {
margin-left: -16px;
margin-right: -16px;
display: none;
margin-bottom: 16px;
}
-.c8 {
+.c9 {
height: 100%;
display: none;
margin-right: 16px;
}
-.c10 {
+.c11 {
-webkit-order: 4;
-ms-flex-order: 4;
order: 4;
@@ -100,26 +106,26 @@ exports[`PageLayout renders condensed layout 1`] = `
}
@media screen and (min-width:768px) {
- .c9 {
+ .c10 {
width: 256px;
}
}
@media screen and (min-width:1012px) {
- .c9 {
+ .c10 {
width: 296px;
}
}
@media screen and (min-width:768px) {
- .c3 {
+ .c4 {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
@media screen and (min-width:768px) {
- .c6 {
+ .c7 {
width: auto;
margin-left: 16px;
margin-top: 0 !important;
@@ -134,7 +140,7 @@ exports[`PageLayout renders condensed layout 1`] = `
}
@media screen and (min-width:768px) {
- .c7 {
+ .c8 {
margin-left: 0 !important;
margin-right: 0 !important;
}
@@ -150,42 +156,50 @@ exports[`PageLayout renders condensed layout 1`] = `
Content
@@ -219,13 +233,17 @@ exports[`PageLayout renders default layout 1`] = `
}
.c3 {
+ padding: 0;
+}
+
+.c4 {
margin-left: -16px;
margin-right: -16px;
display: none;
margin-top: 16px;
}
-.c4 {
+.c5 {
-webkit-order: 2;
-ms-flex-order: 2;
order: 2;
@@ -242,14 +260,15 @@ exports[`PageLayout renders default layout 1`] = `
min-width: 1px;
}
-.c5 {
+.c6 {
width: 100%;
max-width: 100%;
margin-left: auto;
margin-right: auto;
+ padding: 0;
}
-.c6 {
+.c7 {
-webkit-order: 3;
-ms-flex-order: 3;
order: 3;
@@ -266,24 +285,25 @@ exports[`PageLayout renders default layout 1`] = `
margin-top: 16px;
}
-.c7 {
+.c8 {
margin-left: -16px;
margin-right: -16px;
display: none;
margin-bottom: 16px;
}
-.c8 {
+.c9 {
height: 100%;
display: none;
margin-right: 16px;
}
-.c9 {
+.c10 {
width: 100%;
+ padding: 0;
}
-.c10 {
+.c11 {
-webkit-order: 4;
-ms-flex-order: 4;
order: 4;
@@ -304,14 +324,14 @@ exports[`PageLayout renders default layout 1`] = `
}
@media screen and (min-width:768px) {
- .c3 {
+ .c4 {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
@media screen and (min-width:1012px) {
- .c3 {
+ .c4 {
margin-left: -24px;
margin-right: -24px;
margin-top: 24px;
@@ -319,7 +339,7 @@ exports[`PageLayout renders default layout 1`] = `
}
@media screen and (min-width:768px) {
- .c6 {
+ .c7 {
width: auto;
margin-left: 16px;
margin-top: 0 !important;
@@ -334,20 +354,20 @@ exports[`PageLayout renders default layout 1`] = `
}
@media screen and (min-width:1012px) {
- .c6 {
+ .c7 {
margin-top: 24px;
}
}
@media screen and (min-width:768px) {
- .c7 {
+ .c8 {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
@media screen and (min-width:1012px) {
- .c7 {
+ .c8 {
margin-left: -24px;
margin-right: -24px;
margin-bottom: 24px;
@@ -355,25 +375,25 @@ exports[`PageLayout renders default layout 1`] = `
}
@media screen and (min-width:1012px) {
- .c8 {
+ .c9 {
margin-right: 24px;
}
}
@media screen and (min-width:768px) {
- .c9 {
+ .c10 {
width: 256px;
}
}
@media screen and (min-width:1012px) {
- .c9 {
+ .c10 {
width: 296px;
}
}
@media screen and (min-width:1012px) {
- .c10 {
+ .c11 {
margin-top: 24px;
}
}
@@ -388,42 +408,50 @@ exports[`PageLayout renders default layout 1`] = `
Content
@@ -457,13 +485,17 @@ exports[`PageLayout renders pane in different position when narrow 1`] = `
}
.c3 {
+ padding: 0;
+}
+
+.c4 {
margin-left: -16px;
margin-right: -16px;
display: none;
margin-top: 16px;
}
-.c4 {
+.c5 {
-webkit-order: 2;
-ms-flex-order: 2;
order: 2;
@@ -480,14 +512,15 @@ exports[`PageLayout renders pane in different position when narrow 1`] = `
min-width: 1px;
}
-.c5 {
+.c6 {
width: 100%;
max-width: 100%;
margin-left: auto;
margin-right: auto;
+ padding: 0;
}
-.c6 {
+.c7 {
-webkit-order: 3;
-ms-flex-order: 3;
order: 3;
@@ -504,24 +537,25 @@ exports[`PageLayout renders pane in different position when narrow 1`] = `
margin-top: 16px;
}
-.c7 {
+.c8 {
margin-left: -16px;
margin-right: -16px;
display: none;
margin-bottom: 16px;
}
-.c8 {
+.c9 {
height: 100%;
display: none;
margin-right: 16px;
}
-.c9 {
+.c10 {
width: 100%;
+ padding: 0;
}
-.c10 {
+.c11 {
-webkit-order: 4;
-ms-flex-order: 4;
order: 4;
@@ -542,14 +576,14 @@ exports[`PageLayout renders pane in different position when narrow 1`] = `
}
@media screen and (min-width:768px) {
- .c3 {
+ .c4 {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
@media screen and (min-width:1012px) {
- .c3 {
+ .c4 {
margin-left: -24px;
margin-right: -24px;
margin-top: 24px;
@@ -557,7 +591,7 @@ exports[`PageLayout renders pane in different position when narrow 1`] = `
}
@media screen and (min-width:768px) {
- .c6 {
+ .c7 {
width: auto;
margin-left: 16px;
margin-top: 0 !important;
@@ -572,20 +606,20 @@ exports[`PageLayout renders pane in different position when narrow 1`] = `
}
@media screen and (min-width:1012px) {
- .c6 {
+ .c7 {
margin-top: 24px;
}
}
@media screen and (min-width:768px) {
- .c7 {
+ .c8 {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
@media screen and (min-width:1012px) {
- .c7 {
+ .c8 {
margin-left: -24px;
margin-right: -24px;
margin-bottom: 24px;
@@ -593,25 +627,25 @@ exports[`PageLayout renders pane in different position when narrow 1`] = `
}
@media screen and (min-width:1012px) {
- .c8 {
+ .c9 {
margin-right: 24px;
}
}
@media screen and (min-width:768px) {
- .c9 {
+ .c10 {
width: 256px;
}
}
@media screen and (min-width:1012px) {
- .c9 {
+ .c10 {
width: 296px;
}
}
@media screen and (min-width:1012px) {
- .c10 {
+ .c11 {
margin-top: 24px;
}
}
@@ -626,42 +660,50 @@ exports[`PageLayout renders pane in different position when narrow 1`] = `
Content
@@ -695,13 +737,17 @@ exports[`PageLayout renders with dividers 1`] = `
}
.c3 {
+ padding: 0;
+}
+
+.c4 {
margin-left: -16px;
margin-right: -16px;
display: none;
margin-top: 16px;
}
-.c4 {
+.c5 {
-webkit-order: 2;
-ms-flex-order: 2;
order: 2;
@@ -718,25 +764,27 @@ exports[`PageLayout renders with dividers 1`] = `
min-width: 1px;
}
-.c5 {
+.c6 {
width: 100%;
max-width: 100%;
margin-left: auto;
margin-right: auto;
+ padding: 0;
}
-.c10 {
+.c11 {
margin-left: -16px;
margin-right: -16px;
display: none;
margin-bottom: 16px;
}
-.c8 {
+.c9 {
width: 100%;
+ padding: 0;
}
-.c9 {
+.c10 {
-webkit-order: 4;
-ms-flex-order: 4;
order: 4;
@@ -744,7 +792,7 @@ exports[`PageLayout renders with dividers 1`] = `
margin-top: 16px;
}
-.c6 {
+.c7 {
-webkit-order: 1;
-ms-flex-order: 1;
order: 1;
@@ -761,7 +809,7 @@ exports[`PageLayout renders with dividers 1`] = `
margin-bottom: 16px;
}
-.c7 {
+.c8 {
height: 100%;
display: none;
margin-left: 16px;
@@ -780,14 +828,14 @@ exports[`PageLayout renders with dividers 1`] = `
}
@media screen and (min-width:768px) {
- .c3 {
+ .c4 {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
@media screen and (min-width:1012px) {
- .c3 {
+ .c4 {
margin-left: -24px;
margin-right: -24px;
margin-top: 24px;
@@ -795,14 +843,14 @@ exports[`PageLayout renders with dividers 1`] = `
}
@media screen and (min-width:768px) {
- .c10 {
+ .c11 {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
@media screen and (min-width:1012px) {
- .c10 {
+ .c11 {
margin-left: -24px;
margin-right: -24px;
margin-bottom: 24px;
@@ -810,25 +858,25 @@ exports[`PageLayout renders with dividers 1`] = `
}
@media screen and (min-width:768px) {
- .c8 {
+ .c9 {
width: 256px;
}
}
@media screen and (min-width:1012px) {
- .c8 {
+ .c9 {
width: 296px;
}
}
@media screen and (min-width:1012px) {
- .c9 {
+ .c10 {
margin-top: 24px;
}
}
@media screen and (min-width:768px) {
- .c6 {
+ .c7 {
width: auto;
margin-right: 16px;
margin-top: 0 !important;
@@ -843,13 +891,13 @@ exports[`PageLayout renders with dividers 1`] = `
}
@media screen and (min-width:1012px) {
- .c6 {
+ .c7 {
margin-bottom: 24px;
}
}
@media screen and (min-width:1012px) {
- .c7 {
+ .c8 {
margin-left: 24px;
}
}
@@ -864,42 +912,50 @@ exports[`PageLayout renders with dividers 1`] = `
Content
diff --git a/src/drafts/InlineAutocomplete/InlineAutocomplete.stories.tsx b/src/drafts/InlineAutocomplete/InlineAutocomplete.stories.tsx
new file mode 100644
index 00000000000..3002500a521
--- /dev/null
+++ b/src/drafts/InlineAutocomplete/InlineAutocomplete.stories.tsx
@@ -0,0 +1,174 @@
+import React, {useState} from 'react'
+import {Meta} from '@storybook/react'
+
+import {
+ BaseStyles,
+ Box,
+ Textarea,
+ ThemeProvider,
+ ActionList,
+ Avatar,
+ ActionListItemProps,
+ FormControl,
+ TextInput
+} from '../..'
+import InlineAutocomplete, {ShowSuggestionsEvent, Suggestions} from '.'
+
+export default {
+ title: 'Forms/InlineAutocomplete',
+ component: InlineAutocomplete,
+ decorators: [
+ Story => {
+ return (
+
+
+ {Story()}
+
+
+ )
+ }
+ ],
+ argTypes: {
+ loading: {
+ name: 'Loading',
+ defaultValue: false,
+ control: {
+ type: 'boolean'
+ }
+ },
+ tabInserts: {
+ name: '`Tab` Key Inserts Suggestions',
+ defaultValue: false,
+ control: {
+ type: 'boolean'
+ }
+ }
+ }
+} as Meta
+
+interface User {
+ login: string
+ name: string
+ avatar: string
+ type: 'user' | 'organization'
+}
+
+const sampleUsers: User[] = [
+ {login: 'monalisa', name: 'Monalisa Octocat', avatar: 'https://avatars.githubusercontent.com/github', type: 'user'},
+ {login: 'primer', name: 'Primer', avatar: 'https://avatars.githubusercontent.com/primer', type: 'organization'},
+ {login: 'github', name: 'GitHub', avatar: 'https://avatars.githubusercontent.com/github', type: 'organization'}
+]
+
+const filteredUsers = (query: string) =>
+ sampleUsers.filter(
+ user =>
+ user.login.toLowerCase().includes(query.toLowerCase()) || user.name.toLowerCase().includes(query.toLowerCase())
+ )
+
+export const Default = ({loading, tabInserts}: ArgProps) => {
+ const [suggestions, setSuggestions] = useState(null)
+
+ const onShowSuggestions = (event: ShowSuggestionsEvent) => {
+ if (loading) {
+ setSuggestions('loading')
+ return
+ }
+
+ setSuggestions(filteredUsers(event.query).map(user => user.login))
+ }
+
+ return (
+
+ Inline Autocomplete Demo
+ Try typing '@' to show user suggestions.
+ setSuggestions(null)}
+ tabInsertsSuggestions={tabInserts}
+ >
+
+
+
+ )
+}
+
+export const SingleLine = ({loading, tabInserts}: ArgProps) => {
+ const [suggestions, setSuggestions] = useState(null)
+
+ const onShowSuggestions = (event: ShowSuggestionsEvent) => {
+ if (loading) {
+ setSuggestions('loading')
+ return
+ }
+
+ setSuggestions(filteredUsers(event.query).map(user => user.login))
+ }
+
+ return (
+
+ Inline Autocomplete Demo
+ Try typing '@' to show user suggestions.
+ setSuggestions(null)}
+ tabInsertsSuggestions={tabInserts}
+ >
+
+
+
+ )
+}
+
+const UserSuggestion = ({user, ...props}: {user: User} & ActionListItemProps) => (
+
+
+
+
+ {user.name} {user.login}
+
+)
+
+type ArgProps = {
+ loading: boolean
+ tabInserts: boolean
+}
+
+export const CustomRendering = ({loading, tabInserts}: ArgProps) => {
+ const [suggestions, setSuggestions] = useState(null)
+
+ const onShowSuggestions = (event: ShowSuggestionsEvent) => {
+ if (loading) {
+ setSuggestions('loading')
+ return
+ }
+
+ setSuggestions(
+ filteredUsers(event.query).map(user => ({
+ value: user.login,
+ render: props =>
+ }))
+ )
+ }
+
+ const onHideSuggestions = () => setSuggestions(null)
+
+ return (
+
+ Inline Autocomplete Demo
+ Try typing '@' to show user suggestions.
+
+
+
+
+ )
+}
diff --git a/src/drafts/InlineAutocomplete/InlineAutocomplete.test.tsx b/src/drafts/InlineAutocomplete/InlineAutocomplete.test.tsx
new file mode 100644
index 00000000000..e17c260b1d5
--- /dev/null
+++ b/src/drafts/InlineAutocomplete/InlineAutocomplete.test.tsx
@@ -0,0 +1,470 @@
+import React, {useLayoutEffect, useState} from 'react'
+import {fireEvent, render, within} from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import InlineAutocomplete, {ShowSuggestionsEvent, Suggestions, Trigger} from '.'
+import FormControl from '../../FormControl'
+import {ActionList} from '../../ActionList'
+import Textarea from '../../Textarea'
+import ThemeProvider from '../../ThemeProvider'
+
+const label = 'Inline Autocomplete'
+
+const users = ['monalisa', 'github', 'primer']
+
+const emojis = ['heart', 'smile', '+1']
+
+const issues = [
+ ['1', 'add emoji feature'],
+ ['2', 'fails to save'],
+ ['3', 'updates too slowly']
+] as const
+
+const triggers: Trigger[] = [
+ {triggerChar: '@'},
+ {triggerChar: ':', keepTriggerCharOnCommit: false},
+ {triggerChar: '#', multiWord: true}
+]
+
+const UncontrolledInlineAutocomplete = ({
+ loading = false,
+ tabInsertsSuggestions
+}: {
+ loading?: boolean
+ tabInsertsSuggestions?: boolean
+}) => {
+ const [suggestions, setSuggestions] = useState([])
+
+ const showUserSuggestions = (query: string) => {
+ const matchingUsers = users.filter(user => user.includes(query))
+ setSuggestions(matchingUsers)
+ }
+
+ const showEmojiSuggestions = (query: string) => {
+ setSuggestions(
+ emojis
+ .filter(emoji => emoji.includes(query))
+ .map(emoji => ({
+ key: emoji,
+ value: `:${emoji}:`,
+ render: props => {emoji}
+ }))
+ )
+ }
+
+ const showIssueSuggestions = (query: string) => {
+ if (loading) {
+ setSuggestions('loading')
+ return
+ }
+
+ setSuggestions(
+ issues
+ .filter(([_, title]) => title.includes(query))
+ .map(([id, title]) => ({
+ key: id,
+ value: id,
+ render: props => {title}
+ }))
+ )
+ }
+
+ const onShowSuggestions = (event: ShowSuggestionsEvent) => {
+ switch (event.trigger.triggerChar) {
+ case ':':
+ showEmojiSuggestions(event.query)
+ break
+ case '@':
+ showUserSuggestions(event.query)
+ break
+ case '#':
+ showIssueSuggestions(event.query)
+ break
+ }
+ }
+
+ useLayoutEffect(() => {
+ // combobox-nav attempts to filter out 'hidden' options by checking if the option has an
+ // offsetHeight or width > 0. In JSDom, all elements have offsetHeight = offsetWidth = 0,
+ // so we need to override at least one to make the class recognize that any options exist.
+ for (const option of document.querySelectorAll('[role=option]'))
+ Object.defineProperty(option, 'offsetHeight', {
+ value: 1,
+ writable: true
+ })
+ })
+
+ return (
+
+
+ {label}
+ setSuggestions([])}
+ triggers={triggers}
+ tabInsertsSuggestions={tabInsertsSuggestions}
+ >
+
+
+
+ {/* gives us another focuseable element to tab to */}
+
+
+ )
+}
+
+describe('InlineAutocomplete', () => {
+ it('forwards label ID to input', () => {
+ const {queryByLabelText} = render()
+
+ expect(queryByLabelText(label)).toBeInTheDocument()
+ })
+
+ it('does not show suggestions initially', () => {
+ const {queryByRole, getByLabelText} = render()
+
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ expect(getByLabelText(label)).not.toHaveAttribute('aria-expanded', 'true')
+ })
+
+ it('does not show suggestions when typing and not triggered', async () => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText} = render()
+
+ await user.type(getByLabelText(label), 'hello world')
+
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('shows suggestions when triggered', async () => {
+ const user = userEvent.setup()
+ const {getByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+
+ expect(getByRole('listbox')).toBeVisible()
+ expect(input).toHaveAttribute('aria-expanded', 'true')
+ })
+
+ it('does not apply ARIA attributes when no suggestions are available', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello')
+
+ expect(input).not.toHaveAttribute('role')
+ expect(input).not.toHaveAttribute('aria-expanded')
+ expect(input).not.toHaveAttribute('aria-controls')
+ expect(input).not.toHaveAttribute('aria-autocomplete')
+ expect(input).not.toHaveAttribute('aria-haspopup')
+ expect(input).not.toHaveAttribute('aria-activedescendant')
+ })
+
+ it('updates ARIA attributes when list is opened', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+
+ expect(input).toHaveAttribute('aria-expanded', 'true')
+ expect(input).toHaveAttribute('role', 'combobox')
+ expect(input).toHaveAttribute('aria-controls')
+ expect(input).toHaveAttribute('aria-autocomplete', 'list')
+ expect(input).toHaveAttribute('aria-haspopup', 'listbox')
+
+ // initially no activedescendant to avoid interrupting typing
+ expect(input).not.toHaveAttribute('aria-activedescendant')
+ })
+
+ it('updates suggestions upon typing more characters', async () => {
+ const user = userEvent.setup()
+ const {getByRole, getByLabelText} = render()
+
+ const input1 = getByLabelText(label)
+ await user.type(input1, 'hello @pr')
+ const list = getByRole('listbox')
+ expect(within(list).queryAllByRole('option')).toHaveLength(1)
+ })
+
+ it('hides suggestions on Escape', async () => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+ expect(queryByRole('listbox')).toBeVisible()
+ await user.keyboard('{Escape}')
+
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('hides suggestions when no results match', async () => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+ expect(queryByRole('listbox')).toBeVisible()
+ await user.keyboard('xyz')
+
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('hides suggestions when there is a space immediately after the trigger', async () => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'see #')
+ expect(queryByRole('listbox')).toBeVisible()
+ await user.keyboard(' ')
+
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('hides suggestions when the input is clicked', async () => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+ expect(queryByRole('listbox')).toBeVisible()
+ await user.click(input)
+
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('hides suggestions when the page is clicked', async () => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText, container} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+ expect(queryByRole('listbox')).toBeVisible()
+ await user.click(container)
+
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('hides suggestions when input is blurred', async () => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+ // eslint-disable-next-line github/no-blur
+ fireEvent.blur(input)
+
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('hides suggestions when trigger character is deleted', async () => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+ expect(queryByRole('listbox')).toBeVisible()
+ await user.keyboard('{Backspace}')
+
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it.each(['{Enter}', ' '])('for single-word triggers: hides suggestions when "%s" pressed', async key => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+
+ expect(queryByRole('listbox')).toBeVisible()
+ await user.keyboard(key)
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it.each(['.', '{Enter}'])('for multi-word triggers: hides suggestions when "%s" pressed', async key => {
+ const user = userEvent.setup()
+ const {queryByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'see #')
+
+ expect(queryByRole('listbox')).toBeVisible()
+ await user.keyboard(key)
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('allows space in query for multi-word triggers', async () => {
+ const user = userEvent.setup()
+ const {getByRole, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'see #fails to')
+
+ expect(within(getByRole('listbox')).queryAllByRole('option')).toHaveLength(1)
+ })
+
+ it('applies the first suggestion on Enter key press', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText, getByRole, queryByRole} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, `hello @`)
+
+ const list = getByRole('listbox')
+ expect(input).not.toHaveAttribute('aria-activedescendant')
+ expect(within(list).queryAllByRole('option')[0]).toHaveAttribute('data-combobox-option-default')
+
+ await user.keyboard('{Enter}')
+
+ expect(input).toHaveValue('hello @monalisa ')
+ expect(input).toHaveFocus()
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('does not apply using Tab when not enabled', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, `hello @`)
+
+ await user.keyboard('{Tab}')
+
+ expect(input).toHaveValue('hello @')
+ expect(input).not.toHaveFocus()
+ })
+
+ it('applies using Tab when enabled', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, `hello @`)
+
+ await user.keyboard('{Tab}')
+
+ expect(input).toHaveValue('hello @monalisa ')
+ })
+
+ it('selects a suggestion with arrow keys', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText, getByRole, queryByRole} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+
+ await user.keyboard('{ArrowDown}')
+ await user.keyboard('{ArrowDown}')
+
+ expect(input).toHaveFocus()
+ expect(input).toHaveAttribute('aria-activedescendant', expect.stringContaining('option-1'))
+ expect(within(getByRole('listbox')).queryAllByRole('option')[1]).toHaveAttribute('aria-selected', 'true')
+
+ await user.keyboard('{Enter}')
+
+ expect(input).toHaveValue('hello @github ')
+ expect(input).toHaveFocus()
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('applies a suggestion when clicked', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText, getByRole, queryByRole} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, `hello @`)
+
+ const option = within(getByRole('listbox')).queryAllByRole('option')[2]
+ await user.click(option)
+
+ expect(input).toHaveValue('hello @primer ')
+ expect(input).toHaveFocus()
+ expect(queryByRole('listbox')).not.toBeInTheDocument()
+ })
+
+ it('applies the value of the suggestion when different from the display text', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'please see #updates')
+ await user.keyboard('{Enter}')
+
+ expect(input).toHaveValue('please see #3 ')
+ })
+
+ it('deletes the trigger character when `keepTriggerCharOnCommit` is false', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'Hello! :sm')
+ await user.keyboard('{Enter}')
+
+ // if the trigger character was not deleted, the value would be "Hello! ::smile:"
+ expect(input).toHaveValue('Hello! :smile: ')
+ })
+
+ it('shows a loading indicator and allows tabbing away when loading', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText, getByRole, queryByText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'please see #')
+
+ const list = getByRole('listbox')
+ expect(within(list).queryAllByRole('option')).toHaveLength(0)
+ expect(queryByText('Loading autocomplete suggestions…')).toBeInTheDocument()
+
+ await user.tab()
+
+ expect(input).not.toHaveFocus()
+ })
+
+ it('queries based on the last trigger character found', async () => {
+ const user = userEvent.setup()
+ const {getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'Hello! #test :')
+ await user.keyboard('{Enter}')
+
+ expect(input).toHaveValue('Hello! #test :heart: ')
+ })
+
+ it('reads out an accessible message when the suggestions become available and change', async () => {
+ const user = userEvent.setup()
+ const {queryByText, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+
+ const statusMessage = queryByText(
+ '3 autocomplete suggestions available; "monalisa" is highlighted. Press Enter to insert.'
+ )
+ expect(statusMessage).toBeInTheDocument()
+ expect(statusMessage).toHaveAttribute('aria-live', 'assertive')
+
+ await user.keyboard('gith')
+ expect(statusMessage).toHaveTextContent(
+ '1 autocomplete suggestion available; "github" is highlighted. Press Enter to insert.'
+ )
+ })
+
+ it('accessible message includes "Tab" when tab insertion is enabled', async () => {
+ const user = userEvent.setup()
+ const {queryByText, getByLabelText} = render()
+
+ const input = getByLabelText(label)
+ await user.type(input, 'hello @')
+
+ const statusMessage = queryByText(
+ '3 autocomplete suggestions available; "monalisa" is highlighted. Press Enter or Tab to insert.'
+ )
+ expect(statusMessage).toBeInTheDocument()
+ })
+})
diff --git a/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx b/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx
new file mode 100644
index 00000000000..189b1586aa5
--- /dev/null
+++ b/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx
@@ -0,0 +1,220 @@
+import React, {cloneElement, useRef} from 'react'
+import Box from '../../Box'
+import {useCombinedRefs} from '../../hooks/useCombinedRefs'
+import {useSyntheticChange} from '../hooks/useSyntheticChange'
+import Portal from '../../Portal'
+import {BetterSystemStyleObject} from '../../sx'
+
+import {ShowSuggestionsEvent, Suggestions, TextInputCompatibleChild, TextInputElement, Trigger} from './types'
+import {
+ augmentHandler,
+ calculateSuggestionsQuery,
+ getAbsoluteCharacterCoordinates,
+ getSuggestionValue,
+ requireChildrenToBeInput
+} from './utils'
+import AutocompleteSuggestions from './_AutocompleteSuggestions'
+
+export type 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
+ /**
+ * If `true`, suggestions will be applied with both `Tab` and `Enter`, instead of just
+ * `Enter`. This may be expected behavior for users used to IDEs, but use caution when
+ * hijacking browser tabbing capability.
+ * @default false
+ */
+ tabInsertsSuggestions?: boolean
+ /**
+ * 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.
+ /**
+ * An `input` or `textarea` compatible component to extend. A compatible component is any
+ * component that forwards a ref and props to an underlying `input` or `textarea` element,
+ * including but not limited to `Input`, `TextArea`, `input`, `textarea`, `styled.input`,
+ * and `styled.textarea`. If the child is not compatible, a runtime `TypeError` will be
+ * thrown.
+ */
+ 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 = () => {
+ // don't do anything
+}
+
+/**
+ * Shows suggestions to complete the current word/phrase the user is actively typing.
+ */
+const InlineAutocomplete = ({
+ triggers,
+ suggestions,
+ onShowSuggestions,
+ onHideSuggestions,
+ sx,
+ children,
+ tabInsertsSuggestions = false,
+ // Forward accessibility props so it works with FormControl
+ ...forwardProps
+}: InlineAutocompleteProps & React.ComponentProps<'textarea' | 'input'>) => {
+ const inputRef = useCombinedRefs(children.ref)
+ const externalInput = requireChildrenToBeInput(children, inputRef)
+
+ const emitSyntheticChange = useSyntheticChange({
+ inputRef,
+ fallbackEventHandler: externalInput.props.onChange ?? noop
+ })
+
+ /** Stores the query that caused the current suggestion list to appear. */
+ const showEventRef = useRef(null)
+
+ const suggestionsVisible = suggestions !== null && suggestions.length > 0
+
+ // The suggestions don't usually move while open, so it seems as though this could be
+ // optimized by only re-rendering when suggestionsVisible changes. However, the user
+ // could move the cursor to a different location using arrow keys and then type a
+ // trigger, which would move the suggestions without closing/reopening them.
+ const suggestionsOffset =
+ inputRef.current && showEventRef.current && suggestionsVisible
+ ? getAbsoluteCharacterCoordinates(
+ inputRef.current,
+ // Position the suggestions at the trigger character, not the current caret position
+ (getSelectionStart(inputRef.current) ?? 0) - showEventRef.current.query.length
+ )
+ : {top: 0, left: 0}
+
+ // User can blur while suggestions are visible with shift+tab
+ const onBlur: React.FocusEventHandler = () => {
+ onHideSuggestions()
+ }
+
+ // Even though the overlay has an Escape listener, it only works when focus is inside
+ // the overlay; in this case the textarea is focused
+ const onKeyDown: React.KeyboardEventHandler = event => {
+ if (suggestionsVisible && event.key === 'Escape') {
+ onHideSuggestions()
+ event.stopPropagation()
+ }
+ }
+
+ const onChange: React.ChangeEventHandler = event => {
+ const selectionStart = getSelectionStart(event.currentTarget)
+ if (selectionStart === null) {
+ onHideSuggestions()
+ return
+ }
+
+ showEventRef.current = calculateSuggestionsQuery(triggers, event.currentTarget.value, selectionStart)
+
+ if (showEventRef.current) {
+ onShowSuggestions(showEventRef.current)
+ } else {
+ onHideSuggestions()
+ }
+ }
+
+ const onCommit = (suggestion: string) => {
+ if (!inputRef.current || !showEventRef.current) return
+ const {query, trigger} = showEventRef.current
+
+ const currentCaretPosition = getSelectionStart(inputRef.current) ?? 0
+ const deleteLength = query.length + trigger.triggerChar.length
+ const startIndex = currentCaretPosition - deleteLength
+
+ const keepTriggerChar = trigger.keepTriggerCharOnCommit ?? true
+ const maybeTriggerChar = keepTriggerChar ? trigger.triggerChar : ''
+ const replacement = `${maybeTriggerChar}${suggestion} `
+
+ emitSyntheticChange(replacement, [startIndex, startIndex + deleteLength])
+ onHideSuggestions()
+ }
+
+ const input = cloneElement(externalInput, {
+ ...forwardProps,
+ onBlur: augmentHandler(externalInput.props.onBlur, onBlur),
+ onKeyDown: augmentHandler(externalInput.props.onKeyDown, onKeyDown),
+ onChange: augmentHandler(externalInput.props.onChange, onChange),
+ ref: inputRef
+ })
+
+ /**
+ * Even thoughn we apply all the aria attributes, screen readers don't fully support this
+ * dynamic use case and so they don't have a native way to indicate to the user when
+ * there are suggestions available. So we use some hidden text with aria-live to politely
+ * indicate what's available and how to use it.
+ *
+ * This text should be consistent and the important info should be first, because users
+ * will hear it as they type - if they have heard the message before they should be able
+ * to recognize it and quickly apply the first suggestion without listening to the rest
+ * of the message.
+ *
+ * When screen reader users navigate using arrow keys, the `aria-activedescendant` will
+ * change and will be read out so we don't need to handle that interaction here.
+ */
+ const suggestionsDescription = !suggestionsVisible
+ ? ''
+ : suggestions === 'loading'
+ ? 'Loading autocomplete suggestions…'
+ : // It's important to include both Enter and Tab because we are telling the user that we are hijacking these keys:
+ `${suggestions.length} autocomplete ${
+ suggestions.length === 1 ? 'suggestion' : 'suggestions'
+ } available; "${getSuggestionValue(suggestions[0])}" is highlighted. Press ${
+ tabInsertsSuggestions ? 'Enter or Tab' : 'Enter'
+ } to insert.`
+
+ return (
+ // Try to get as close as possible to making the container 'invisible' by making it shrink tight to child input
+ *': {width: '100%'}, ...sx, position: 'relative'}}>
+ {input}
+
+
+
+ {/* This should NOT be linked to the input with aria-describedby or screen readers may not read the live updates.
+ The assertive live attribute ensures the suggestions are read instead of the input label, which voiceover will try to re-read when the role changes. */}
+
+ {suggestionsDescription}
+
+
+
+ )
+}
+
+export default InlineAutocomplete
diff --git a/src/drafts/InlineAutocomplete/_AutocompleteSuggestions.tsx b/src/drafts/InlineAutocomplete/_AutocompleteSuggestions.tsx
new file mode 100644
index 00000000000..181dc6a58a5
--- /dev/null
+++ b/src/drafts/InlineAutocomplete/_AutocompleteSuggestions.tsx
@@ -0,0 +1,119 @@
+import React, {useCallback, useState} from 'react'
+import {Spinner} from '../..'
+import {ActionList, ActionListItemProps} from '../../ActionList'
+import Box from '../../Box'
+import {ComboboxCommitEvent, useCombobox} from '../hooks/useCombobox'
+import Overlay from '../../Overlay'
+
+import {Suggestion, Suggestions, TextInputElement} from './types'
+import {getSuggestionKey, getSuggestionValue} from './utils'
+
+type AutoCompleteSuggestionsProps = {
+ suggestions: Suggestions | null
+ portalName?: string
+ // make top/left primitives instead of a Coordinates object to avoid extra re-renders
+ top: number
+ left: number
+ onClose: () => void
+ onCommit: (suggestion: string) => void
+ inputRef: React.RefObject
+ visible: boolean
+ tabInsertsSuggestions: boolean
+}
+
+const LoadingIndicator = () => (
+
+
+
+)
+
+const SuggestionListItem = ({suggestion}: {suggestion: Suggestion}) => {
+ const value = getSuggestionValue(suggestion)
+
+ const sharedProps: ActionListItemProps = {
+ id: value,
+ children: value,
+ role: 'option',
+ sx: {
+ '&[aria-selected]': {
+ backgroundColor: 'actionListItem.default.activeBg'
+ },
+ '&[data-combobox-option-default]:not([aria-selected])': {
+ backgroundColor: 'actionListItem.default.selectedBg'
+ }
+ }
+ }
+
+ return typeof suggestion === 'string' ? : suggestion.render(sharedProps)
+}
+
+/**
+ * Renders an overlayed list at the given relative coordinates. Handles keyboard navigation
+ * and accessibility concerns.
+ */
+const AutocompleteSuggestions = ({
+ suggestions,
+ portalName,
+ top,
+ left,
+ onClose,
+ onCommit: externalOnCommit,
+ inputRef,
+ visible,
+ tabInsertsSuggestions
+}: AutoCompleteSuggestionsProps) => {
+ // It seems wierd to use state instead of a ref here, but because the list is inside an
+ // AnchoredOverlay it is not always mounted - so we want to reinitialize the Combobox when it mounts
+ const [list, setList] = useState(null)
+
+ const onCommit = useCallback(
+ ({option}: ComboboxCommitEvent) => {
+ externalOnCommit(getSuggestionValue(option))
+ },
+ [externalOnCommit]
+ )
+
+ // Setup keyboard navigation
+ useCombobox({
+ // Even though the list is visible when loading, we don't want to do keyboard binding in that case
+ isOpen: visible && suggestions !== 'loading',
+ listElement: list,
+ inputElement: inputRef.current,
+ onCommit,
+ options: Array.isArray(suggestions) ? suggestions : [],
+ tabInsertsSuggestions,
+ defaultFirstOption: true
+ })
+
+ // Conditional rendering appears wrong at first - it means that we are reconstructing the
+ // Combobox instance every time the suggestions appear. But this is what we want - otherwise
+ // the textarea would always have the `combobox` role, which is incorrect (a textarea should
+ // not technically ever be a combobox). We compromise by dynamically applying the combobox
+ // role only when suggestions are available.
+ return visible ? (
+
+
+ {suggestions === 'loading' ? (
+
+ ) : (
+ suggestions?.map(suggestion => (
+
+ ))
+ )}
+
+
+ ) : (
+ <>>
+ )
+}
+AutocompleteSuggestions.displayName = 'SuggestionList'
+
+export default AutocompleteSuggestions
diff --git a/src/drafts/InlineAutocomplete/index.ts b/src/drafts/InlineAutocomplete/index.ts
new file mode 100644
index 00000000000..0e9a439c64d
--- /dev/null
+++ b/src/drafts/InlineAutocomplete/index.ts
@@ -0,0 +1,6 @@
+import InlineAutocomplete from './InlineAutocomplete'
+
+export type {InlineAutocompleteProps} from './InlineAutocomplete'
+export type {ShowSuggestionsEvent, Suggestion, Suggestions, Trigger} from './types'
+
+export default InlineAutocomplete
diff --git a/src/drafts/InlineAutocomplete/types.ts b/src/drafts/InlineAutocomplete/types.ts
new file mode 100644
index 00000000000..a721f9c63ca
--- /dev/null
+++ b/src/drafts/InlineAutocomplete/types.ts
@@ -0,0 +1,56 @@
+import {ActionListItemProps} from '../../ActionList'
+
+export type Trigger = {
+ /** A single character that can cause the suggestion list to open. */
+ triggerChar: string
+ /**
+ * Control whether the suggestion query can contain spaces. This should *not* be `true` if
+ * `triggerChar` is a space.
+ * @default false
+ */
+ multiWord?: boolean
+ /**
+ * Control whether the trigger character is retained when inserting a suggestion.
+ * @default true
+ */
+ keepTriggerCharOnCommit?: boolean
+}
+
+export type ShowSuggestionsEvent = {
+ /** The trigger that caused this query. */
+ trigger: Trigger
+ /** The query string. */
+ query: string
+}
+
+export type Suggestion =
+ | string
+ | {
+ /**
+ * The plain text value of the suggestion. This is the text that will be inserted when
+ * the user applies the suggestion. If no `key` is provided, this value **must** be unique
+ * across all currently visible suggestions.
+ */
+ value: string
+ /**
+ * Optional key. If not provided, the `value` will be used. Setting a `key` allows
+ * for non-unique `value`s.
+ */
+ key?: string
+ /** This must return an `ActionList.Item` instance. */
+ render: (props: ActionListItemProps) => React.ReactElement
+ }
+
+export type Suggestions = Array | 'loading'
+
+export type Coordinates = {
+ top: number
+ left: number
+}
+
+export type TextInputElement = HTMLInputElement | HTMLTextAreaElement
+
+export type TextInputCompatibleChild = React.ReactElement<
+ JSX.IntrinsicElements['textarea'] | JSX.IntrinsicElements['input']
+> &
+ React.RefAttributes
diff --git a/src/drafts/InlineAutocomplete/utils.ts b/src/drafts/InlineAutocomplete/utils.ts
new file mode 100644
index 00000000000..c6a14703439
--- /dev/null
+++ b/src/drafts/InlineAutocomplete/utils.ts
@@ -0,0 +1,160 @@
+import getCaretCoordinates from '@koddsson/textarea-caret'
+import {Children, EventHandler, SyntheticEvent} from 'react'
+
+import {Coordinates, ShowSuggestionsEvent, Suggestion, TextInputCompatibleChild, Trigger} from './types'
+
+const singleWordTriggerTerminators = new Set([' ', '\n'])
+const multiWordTriggerTerminators = new Set(['.', '\n'])
+
+const isWhitespace = (char: string) => /\s/.test(char)
+
+/**
+ * Calculate whether or not suggestions should be shown based on the given state of the
+ * input. If they should be shown, returns the show event.
+ */
+export const calculateSuggestionsQuery = (
+ triggers: Array,
+ text: string,
+ caretLocation: number
+): ShowSuggestionsEvent | null => {
+ // Build backwards from the caret location until the most recent trigger character or terminator
+ for (
+ let i = caretLocation - 1, query = '', potentialTriggers = triggers;
+ i >= 0 && potentialTriggers.length > 0;
+ i--
+ ) {
+ const character = text[i]
+
+ if (singleWordTriggerTerminators.has(character)) potentialTriggers = potentialTriggers.filter(t => t.multiWord)
+ if (multiWordTriggerTerminators.has(character)) potentialTriggers = potentialTriggers.filter(t => !t.multiWord)
+
+ for (const trigger of potentialTriggers.filter(t => character === t.triggerChar)) {
+ // Trigger chars must always be preceded by whitespace or be the first character in the input,
+ // and even a multi-word query cannot start with whitespace
+ if ((i === 0 || isWhitespace(text[i - 1])) && !isWhitespace(query[0])) return {trigger, query}
+
+ potentialTriggers = potentialTriggers.filter(t => t !== trigger)
+ }
+
+ query = character + query
+ }
+
+ return null
+}
+
+/**
+ * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the
+ * top-left corner of the input itself.
+ * @param input The target input element.
+ * @param index The index of the character to calculate for.
+ * @param adjustForScroll Control whether the returned value is adjusted based on scroll position.
+ */
+export const getCharacterCoordinates = (
+ input: HTMLTextAreaElement | HTMLInputElement | null,
+ index: number,
+ adjustForScroll = true
+): Coordinates => {
+ if (!input) return {top: 0, left: 0}
+
+ // word-wrap:break-word breaks the getCaretCoordinates calculations (a bug), and word-wrap has
+ // no effect on input element anyway
+ if (input instanceof HTMLInputElement) input.style.wordWrap = ''
+
+ let coords = getCaretCoordinates(input, index)
+
+ // The library calls parseInt on the computed line-height of the element, failing to account for
+ // the possibility of it being 'normal' (another bug). In that case, fall back to a rough guess
+ // of 1.2 based on MDN: "Desktop browsers use a default value of roughly 1.2".
+ if (isNaN(coords.height)) coords.height = parseInt(getComputedStyle(input).fontSize) * 1.2
+
+ // Sometimes top is negative, incorrectly, because of the wierd line-height calculations around
+ // border-box sized single-line inputs.
+ coords.top = Math.abs(coords.top)
+
+ // For some single-line inputs, the rightmost character can be accidentally wrapped even with the
+ // wordWrap fix above. If this happens, go back to the last usable index
+ let adjustedIndex = index
+ while (input instanceof HTMLInputElement && coords.top > coords.height) {
+ coords = getCaretCoordinates(input, --adjustedIndex)
+ }
+
+ const scrollTopOffset = adjustForScroll ? -input.scrollTop : 0
+ const scrollLeftOffset = adjustForScroll ? -input.scrollLeft : 0
+
+ return {top: coords.top + coords.height + scrollTopOffset, left: coords.left + scrollLeftOffset}
+}
+
+/**
+ * Obtain the coordinates of the bottom left of a character in an input relative to the top-left
+ * of the page.
+ * @param input The target input element.
+ * @param index The index of the character to calculate for.
+ */
+export const getAbsoluteCharacterCoordinates = (
+ input: HTMLTextAreaElement | HTMLInputElement | null,
+ index: number
+): Coordinates => {
+ const {top: relativeTop, left: relativeLeft} = getCharacterCoordinates(input, index, true)
+ const {top: viewportOffsetTop, left: viewportOffsetLeft} = input?.getBoundingClientRect() ?? {top: 0, left: 0}
+
+ return {
+ top: viewportOffsetTop + relativeTop,
+ left: viewportOffsetLeft + relativeLeft
+ }
+}
+
+export const getSuggestionValue = (suggestion: Suggestion): string =>
+ typeof suggestion === 'string' ? suggestion : suggestion.value
+
+export const getSuggestionKey = (suggestion: Suggestion): string =>
+ typeof suggestion === 'string' ? suggestion : suggestion.key ?? suggestion.value
+
+/**
+ * Replace a section of a string.
+ */
+export const replaceSlice = (
+ original: string,
+ [startInclusive, endExclusive]: [number, number],
+ replacement: string
+) => {
+ const before = original.substring(0, startInclusive)
+ const after = original.substring(endExclusive)
+ return before + replacement + after
+}
+
+/**
+ * Attempts to assert that the child element is of a supported type. This can't be enforced
+ * by the type system so it has to be done as a runtime check. This isn't foolproof - a
+ * component that forwards a ref to a correct element but does not forward event handlers
+ * will not work. But it's the best we can reasonably do.
+ */
+export function requireChildrenToBeInput(
+ child: React.ReactElement,
+ childRef: React.RefObject
+): TextInputCompatibleChild {
+ Children.only(child) // Assert that the child is lonely
+ if (
+ // There is no way to know what type the underlying child is until it mounts, so this
+ // will always pass on first render before failing on the second render
+ childRef.current &&
+ !(childRef.current instanceof HTMLInputElement) &&
+ !(childRef.current instanceof HTMLTextAreaElement)
+ ) {
+ throw new TypeError(
+ `AutocompleteTextarea child must be a component that forwards a ref and props to an or