diff --git a/.changeset/smooth-lemons-brake.md b/.changeset/smooth-lemons-brake.md
new file mode 100644
index 00000000000..4a68284bebf
--- /dev/null
+++ b/.changeset/smooth-lemons-brake.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+Adds a loadings state to our text input components
diff --git a/docs/content/TextInput.mdx b/docs/content/TextInput.mdx
index b3bdfe57be6..bb98bb7113c 100644
--- a/docs/content/TextInput.mdx
+++ b/docs/content/TextInput.mdx
@@ -9,13 +9,15 @@ TextInput is a form component to add default styling to the native text input.
**Note:** Don't forget to set `aria-label` to make the TextInput accessible to screen reader users.
-## Default example
+## Examples
+
+### Basic
```jsx live
```
-## Text Input with icons
+### With icons
```jsx live
<>
@@ -37,7 +39,7 @@ TextInput is a form component to add default styling to the native text input.
>
```
-## Text Input with text visuals
+### With text visuals
```jsx live
<>
@@ -47,7 +49,66 @@ TextInput is a form component to add default styling to the native text input.
>
```
-## Text Input with error and warning states
+### With visuals and loading indicators
+
+```javascript live noinline
+const WithIconAndLoadingIndicator = () => {
+ const [loading, setLoading] = React.useState(true)
+
+ const toggleLoadingState = () => {
+ setLoading(!loading)
+ }
+
+ return (
+ <>
+
+
+
+
+
+ No visual
+
+
+
+
+
+
+ Leading visual
+
+
+
+
+
+
+ Trailing visual
+
+
+
+
+
+
+ Both visuals
+
+
+
+
+
+
+ Both visuals, position overriden
+
+
+
+
+ >
+ )
+}
+
+render()
+```
+
+### With error and warning states
```jsx live
<>
@@ -69,19 +130,19 @@ TextInput is a form component to add default styling to the native text input.
>
```
-## Block text input
+### Block text input
```jsx live
```
-## Contrast text input
+### Contrast text input
```jsx live
```
-## Monospace text input
+### Monospace text input
```jsx live
- Creates a full width input element
- >
- }
+ description="Creates a full-width input element"
/>
-
+
+
+
Which position to render the loading indicator
+
+
+ 'auto' (default): at the end of the input, unless a `leadingVisual` is passed. Then, it will render at the
+ beginning
+
+
'leading': at the beginning of the input
+
'trailing': at the end of the input
+
+ >
+ }
+/>
string | React.ComponentType>}
description="Visual positioned on the left edge inside the input"
/>
+string | React.ComponentType>}
@@ -138,7 +208,6 @@ TextInput is a form component to add default styling to the native text input.
type="'error' | 'success' | 'warning'"
description="Style the input to match the status"
/>
-
{
render(LeadingVisualExample)
```
+## With visuals and loading indicators
+
+```javascript live noinline
+const WithIconAndLoadingIndicator = () => {
+ const [dates, setDates] = React.useState([
+ {text: '01 Jan', id: 0},
+ {text: '01 Feb', id: 1},
+ {text: '01 Mar', id: 2}
+ ])
+ const onDateRemove = tokenId => {
+ setDates(dates.filter(token => token.id !== tokenId))
+ }
+
+ const [loading, setLoading] = React.useState(true)
+ const toggleLoadingState = () => {
+ setLoading(!loading)
+ }
+
+ return (
+ <>
+
+
+
+
+
No visual
+
+
+
+
+
+
+
+
+
+
+
Leading visual
+
+
+
+
+
+
+
+
+
+
+
Trailing visual
+
+
+
+
+
+
+
+
+
+
+
Both visuals
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+render()
+```
+
## Props
diff --git a/src/TextInput.tsx b/src/TextInput.tsx
index 6c1a21d8e6d..08f59f3ea89 100644
--- a/src/TextInput.tsx
+++ b/src/TextInput.tsx
@@ -1,14 +1,32 @@
+import React, {MouseEventHandler} from 'react'
import {ForwardRefComponent as PolymorphicForwardRefComponent} from '@radix-ui/react-polymorphic'
import classnames from 'classnames'
-import React from 'react'
+
+import TextInputInnerVisualSlot from './_TextInputInnerVisualSlot'
+import {useProvidedRefOrCreate} from './hooks'
import {Merge} from './utils/types'
import TextInputWrapper, {StyledWrapperProps} from './_TextInputWrapper'
import UnstyledTextInput from './_UnstyledTextInput'
-type NonPassthroughProps = {
+export type TextInputNonPassthroughProps = {
/** @deprecated Use `leadingVisual` or `trailingVisual` prop instead */
icon?: React.ComponentType<{className?: string}>
+ /** Whether the to show a loading indicator in the input */
+ loading?: boolean
+ /**
+ * Which position to render the loading indicator
+ * 'auto' (default): at the end of the input, unless a `leadingVisual` is passed. Then, it will render at the beginning
+ * 'leading': at the beginning of the input
+ * 'trailing': at the end of the input
+ **/
+ loaderPosition?: 'auto' | 'leading' | 'trailing'
+ /**
+ * A visual that renders inside the input before the typing area
+ */
leadingVisual?: string | React.ComponentType<{className?: string}>
+ /**
+ * A visual that renders inside the input after the typing area
+ */
trailingVisual?: string | React.ComponentType<{className?: string}>
} & Pick<
StyledWrapperProps,
@@ -25,7 +43,7 @@ type NonPassthroughProps = {
| 'validationStatus'
>
-export type TextInputProps = Merge, NonPassthroughProps>
+export type TextInputProps = Merge, TextInputNonPassthroughProps>
// using forwardRef is important so that other components (ex. SelectMenu) can autofocus the input
const TextInput = React.forwardRef(
@@ -38,6 +56,8 @@ const TextInput = React.forwardRef(
className,
contrast,
disabled,
+ loading,
+ loaderPosition,
monospace,
validationStatus,
sx: sxProp,
@@ -52,8 +72,16 @@ const TextInput = React.forwardRef(
},
ref
) => {
+ const inputRef = useProvidedRefOrCreate(ref as React.RefObject)
// this class is necessary to style FilterSearch, plz no touchy!
const wrapperClasses = classnames(className, 'TextInput-wrapper')
+ const showLeadingLoadingIndicator =
+ loading && (loaderPosition === 'leading' || Boolean(LeadingVisual && loaderPosition !== 'trailing'))
+ const showTrailingLoadingIndicator =
+ loading && (loaderPosition === 'trailing' || Boolean(loaderPosition === 'auto' && !LeadingVisual))
+ const focusInput: MouseEventHandler = () => {
+ inputRef.current?.focus()
+ }
return (
(
minWidth={minWidthProp}
maxWidth={maxWidthProp}
variant={variantProp}
- hasLeadingVisual={Boolean(LeadingVisual)}
- hasTrailingVisual={Boolean(TrailingVisual)}
+ hasLeadingVisual={Boolean(LeadingVisual || showLeadingLoadingIndicator)}
+ hasTrailingVisual={Boolean(TrailingVisual || showTrailingLoadingIndicator)}
+ onClick={focusInput}
+ aria-live="polite"
+ aria-busy={Boolean(loading)}
>
{IconComponent && }
- {LeadingVisual && (
-
- {typeof LeadingVisual === 'function' ? : LeadingVisual}
-
- )}
-
- {TrailingVisual && (
-
- {typeof TrailingVisual === 'function' ? : TrailingVisual}
-
- )}
+
+ {typeof LeadingVisual === 'function' ? : LeadingVisual}
+
+
+
+ {typeof TrailingVisual === 'function' ? : TrailingVisual}
+
)
}
) as PolymorphicForwardRefComponent<'input', TextInputProps>
TextInput.defaultProps = {
- type: 'text'
+ type: 'text',
+ loaderPosition: 'auto'
}
TextInput.displayName = 'TextInput'
diff --git a/src/TextInputWithTokens.tsx b/src/TextInputWithTokens.tsx
index f43c19f606f..4ab12930433 100644
--- a/src/TextInputWithTokens.tsx
+++ b/src/TextInputWithTokens.tsx
@@ -10,6 +10,7 @@ import Text from './Text'
import {TextInputProps} from './TextInput'
import Token from './Token/Token'
import {TokenSizeKeys} from './Token/TokenBase'
+import TextInputInnerVisualSlot from './_TextInputInnerVisualSlot'
import TextInputWrapper, {textInputHorizPadding, TextInputSizes} from './_TextInputWrapper'
import UnstyledTextInput from './_UnstyledTextInput'
@@ -67,6 +68,8 @@ function TextInputWithTokensInnerComponent
{IconComponent && !LeadingVisual && }
- {LeadingVisual && !IconComponent && (
-
- {typeof LeadingVisual === 'function' ? : LeadingVisual}
-
- )}
+
+ {typeof LeadingVisual === 'function' ? : LeadingVisual}
+ }
display="flex"
@@ -346,11 +355,13 @@ function TextInputWithTokensInnerComponent
) : null}
- {TrailingVisual && (
-
- {typeof TrailingVisual === 'function' ? : TrailingVisual}
-
- )}
+
+ {typeof TrailingVisual === 'function' ? : TrailingVisual}
+
)
}
@@ -361,7 +372,8 @@ TextInputWithTokens.defaultProps = {
tokenComponent: Token,
size: 'extralarge',
hideTokenRemoveButtons: false,
- preventTokenWrapping: false
+ preventTokenWrapping: false,
+ loaderPosition: 'auto'
}
TextInputWithTokens.displayName = 'TextInputWithTokens'
diff --git a/src/_TextInputInnerVisualSlot.tsx b/src/_TextInputInnerVisualSlot.tsx
new file mode 100644
index 00000000000..aac487b11e8
--- /dev/null
+++ b/src/_TextInputInnerVisualSlot.tsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import {Box, Spinner} from '.'
+import {TextInputNonPassthroughProps} from './TextInput'
+
+const TextInputInnerVisualSlot: React.FC<{
+ /** Whether the input is expected to ever show a loading indicator */
+ hasLoadingIndicator: boolean
+ /** Whether the to show the loading indicator */
+ showLoadingIndicator: TextInputNonPassthroughProps['loading']
+ /** Which side of this visual is being rendered */
+ visualPosition: 'leading' | 'trailing'
+}> = ({children, hasLoadingIndicator, showLoadingIndicator, visualPosition}) => {
+ if ((!children && !hasLoadingIndicator) || (visualPosition === 'leading' && !children && !showLoadingIndicator)) {
+ return null
+ }
+
+ if (!hasLoadingIndicator) {
+ return {children}
+ }
+
+ return (
+
+
+ {children && {children}}
+
+
+
+ )
+}
+
+export default TextInputInnerVisualSlot
diff --git a/src/__tests__/TextInput.test.tsx b/src/__tests__/TextInput.test.tsx
index 09cc2439d77..a99c5eee014 100644
--- a/src/__tests__/TextInput.test.tsx
+++ b/src/__tests__/TextInput.test.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import {TextInput} from '..'
import {render, mount, behavesAsComponent, checkExports} from '../utils/testing'
-import {render as HTMLRender, cleanup} from '@testing-library/react'
+import {render as HTMLRender, cleanup, fireEvent} from '@testing-library/react'
import {axe, toHaveNoViolations} from 'jest-axe'
import 'babel-polyfill'
import {SearchIcon} from '@primer/octicons-react'
@@ -64,6 +64,72 @@ describe('TextInput', () => {
expect(render()).toMatchSnapshot()
})
+ it('focuses the text input if you do not click the input element', () => {
+ const {container, getByLabelText} = HTMLRender(
+ <>
+ {/* eslint-disable-next-line jsx-a11y/label-has-for */}
+
+
+ >
+ )
+
+ const icon = container.querySelector('svg')!
+
+ expect(getByLabelText('Search')).not.toEqual(document.activeElement)
+ fireEvent.click(icon)
+ expect(getByLabelText('Search')).toEqual(document.activeElement)
+ })
+
+ it('renders with a loading indicator', () => {
+ expect(
+ render(
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ ).toMatchSnapshot()
+ })
+
+ it('indicates a busy status to assistive technology', () => {
+ const {container} = HTMLRender(
+ <>
+ {/* eslint-disable-next-line jsx-a11y/label-has-for */}
+
+
+ >
+ )
+
+ expect(container.querySelector('span[aria-busy=true]')).not.toBeNull()
+ })
+
it('should call onChange prop with input value', () => {
const onChangeMock = jest.fn()
const component = mount()
diff --git a/src/__tests__/TextInputWithTokens.test.tsx b/src/__tests__/TextInputWithTokens.test.tsx
index 79cf68a2bad..51cbfd28fc7 100644
--- a/src/__tests__/TextInputWithTokens.test.tsx
+++ b/src/__tests__/TextInputWithTokens.test.tsx
@@ -109,6 +109,95 @@ describe('TextInputWithTokens', () => {
).toMatchSnapshot()
})
+ it('renders with a loading indicator', () => {
+ const onRemoveMock = jest.fn()
+ expect(
+ render(
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ ).toMatchSnapshot()
+ })
+
it('focuses the previous token when keying ArrowLeft', () => {
const onRemoveMock = jest.fn()
const {getByLabelText, getByText} = HTMLRender(
diff --git a/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap b/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap
index e8f6413770e..41a43e1d331 100644
--- a/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap
+++ b/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap
@@ -94,7 +94,10 @@ Array [
}
`;
+exports[`TextInput renders with a loading indicator 1`] = `
+Array [
+ .c2 {
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ position: relative;
+}
+
+.c0 {
+ font-size: 14px;
+ line-height: 20px;
+ color: #24292f;
+ vertical-align: middle;
+ background-color: #ffffff;
+ border: 1px solid #d0d7de;
+ border-radius: 6px;
+ outline: none;
+ box-shadow: inset 0 1px 0 rgba(208,215,222,0.2);
+ cursor: text;
+ display: -webkit-inline-box;
+ display: -webkit-inline-flex;
+ display: -ms-inline-flexbox;
+ display: inline-flex;
+ -webkit-align-items: stretch;
+ -webkit-box-align: stretch;
+ -ms-flex-align: stretch;
+ align-items: stretch;
+ min-height: 32px;
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ padding-left: 0;
+ padding-right: 12px;
+}
+
+.c0::-webkit-input-placeholder {
+ color: #6e7781;
+}
+
+.c0::-moz-placeholder {
+ color: #6e7781;
+}
+
+.c0:-ms-input-placeholder {
+ color: #6e7781;
+}
+
+.c0::placeholder {
+ color: #6e7781;
+}
+
+.c0:focus-within {
+ border-color: #0969da;
+ box-shadow: 0 0 0 3px rgba(9,105,218,0.3);
+}
+
+.c0 > textarea {
+ padding: 12px;
+}
+
+.c0 >:not(:last-child) {
+ margin-right: 8px;
+}
+
+.c0 .TextInput-icon {
+ -webkit-align-self: center;
+ -ms-flex-item-align: center;
+ align-self: center;
+ color: #57606a;
+ -webkit-flex-shrink: 0;
+ -ms-flex-negative: 0;
+ flex-shrink: 0;
+}
+
+.c0 > input,
+.c0 > select {
+ padding-left: 12px;
+ padding-right: 0;
+}
+
+.c1 {
+ border: 0;
+ font-size: inherit;
+ font-family: inherit;
+ background-color: transparent;
+ -webkit-appearance: none;
+ color: inherit;
+ width: 100%;
+}
+
+.c1:focus {
+ outline: 0;
+}
+
+.c3 {
+ -webkit-animation: rotate-keyframes 1s linear infinite;
+ animation: rotate-keyframes 1s linear infinite;
+ visibility: visible;
+}
+
+@media (min-width:768px) {
+ .c0 {
+ font-size: 14px;
+ }
+}
+
+
+
+
+