Skip to content

Commit 2ec36e8

Browse files
committed
Enhance text input component
1 parent 92f0dc6 commit 2ec36e8

File tree

3 files changed

+144
-31
lines changed

3 files changed

+144
-31
lines changed

src/TextInput.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import TextInputWrapper from './_TextInputWrapper'
66

77
type NonPassthroughProps = {
88
className?: string
9+
// deprecate icon prop
910
icon?: React.ComponentType<{className?: string}>
11+
leadingIcon?: React.ComponentType<{className?: string}>
12+
trailingIcon?: React.ComponentType<{className?: string}>
1013
} & Pick<
1114
ComponentProps<typeof TextInputWrapper>,
12-
'block' | 'contrast' | 'disabled' | 'sx' | 'theme' | 'width' | 'maxWidth' | 'minWidth' | 'variant'
15+
'block' | 'contrast' | 'disabled' | 'sx' | 'theme' | 'width' | 'maxWidth' | 'minWidth' | 'variant' | 'size'
1316
>
1417

1518
// Note: using ComponentProps instead of ComponentPropsWithoutRef here would cause a type issue where `css` is a required prop.
@@ -20,16 +23,22 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputInternalProps>(
2023
(
2124
{
2225
icon: IconComponent,
26+
leadingIcon: LeadingIconComponent,
27+
trailingIcon: TrailingIconComponent,
2328
block,
2429
className,
2530
contrast,
2631
disabled,
32+
status,
2733
sx: sxProp,
2834
theme,
35+
size: sizeProp,
36+
// start deprecated props
2937
width: widthProp,
3038
minWidth: minWidthProp,
3139
maxWidth: maxWidthProp,
3240
variant: variantProp,
41+
// end deprecated props
3342
...inputProps
3443
},
3544
ref
@@ -41,18 +50,21 @@ const TextInput = React.forwardRef<HTMLInputElement, TextInputInternalProps>(
4150
<TextInputWrapper
4251
block={block}
4352
className={wrapperClasses}
53+
status={status}
4454
contrast={contrast}
4555
disabled={disabled}
46-
hasIcon={!!IconComponent}
56+
hasIcon={!!IconComponent || !!(LeadingIconComponent || TrailingIconComponent)}
4757
sx={sxProp}
4858
theme={theme}
4959
width={widthProp}
5060
minWidth={minWidthProp}
5161
maxWidth={maxWidthProp}
5262
variant={variantProp}
5363
>
54-
{IconComponent && <IconComponent className="TextInput-icon" />}
55-
<UnstyledTextInput ref={ref} disabled={disabled} {...inputProps} />
64+
{IconComponent && <IconComponent className="TextInput-leading-icon" />}
65+
{LeadingIconComponent && <LeadingIconComponent className="TextInput-leading-icon" />}
66+
<UnstyledTextInput ref={ref} disabled={disabled} {...inputProps} data-component="input" />
67+
{TrailingIconComponent && <TrailingIconComponent className="TextInput-trailing-icon" />}
5668
</TextInputWrapper>
5769
)
5870
}

src/_TextInputWrapper.tsx

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,25 @@ import {maxWidth, MaxWidthProps, minWidth, MinWidthProps, variant, width, WidthP
33
import {get} from './constants'
44
import sx, {SxProp} from './sx'
55

6+
const sizeDeprecatedVariants = variant({
7+
variants: {
8+
small: {
9+
minHeight: '28px',
10+
px: 2,
11+
py: '3px',
12+
fontSize: 0,
13+
lineHeight: '20px'
14+
},
15+
large: {
16+
px: 2,
17+
py: '10px',
18+
fontSize: 3
19+
}
20+
}
21+
})
22+
623
const sizeVariants = variant({
24+
prop: 'size',
725
variants: {
826
small: {
927
minHeight: '28px',
@@ -25,45 +43,49 @@ type StyledWrapperProps = {
2543
hasIcon?: boolean
2644
block?: boolean
2745
contrast?: boolean
46+
status?: 'error' | 'warning'
2847
variant?: 'small' | 'large'
48+
size?: 'small' | 'large'
2949
} & WidthProps &
3050
MinWidthProps &
3151
MaxWidthProps &
3252
SxProp
3353

3454
const TextInputWrapper = styled.span<StyledWrapperProps>`
35-
display: inline-flex;
36-
align-items: stretch;
55+
width: max-content;
3756
min-height: 34px;
3857
font-size: ${get('fontSizes.1')};
3958
line-height: 20px;
4059
color: ${get('colors.fg.default')};
4160
vertical-align: middle;
61+
background-color: ${get('colors.input.bg')};
4262
background-repeat: no-repeat; // Repeat and position set for form states (success, error, etc)
4363
background-position: right 8px center; // For form validation. This keeps images 8px from right and centered vertically.
4464
border: 1px solid ${get('colors.border.default')};
4565
border-radius: ${get('radii.2')};
4666
outline: none;
4767
box-shadow: ${get('shadows.primer.shadow.inset')};
4868
cursor: text;
69+
padding: 6px 12px;
70+
display: grid;
71+
grid-template-areas: 'leadingIcon input trailingIcon';
72+
& > :not(:last-child) {
73+
margin-right: ${get('space.2')};
74+
}
75+
[data-component='input'] {
76+
grid-area: input;
77+
}
4978
50-
${props => {
51-
if (props.hasIcon) {
52-
return css`
53-
padding: 0;
54-
`
55-
} else {
56-
return css`
57-
padding: 6px 12px;
58-
`
59-
}
60-
}}
79+
.TextInput-leading-icon {
80+
align-self: center;
81+
color: ${get('colors.fg.muted')};
82+
grid-area: leadingIcon;
83+
}
6184
62-
.TextInput-icon {
85+
.TextInput-trailing-icon {
6386
align-self: center;
6487
color: ${get('colors.fg.muted')};
65-
margin: 0 ${get('space.2')};
66-
flex-shrink: 0;
88+
grid-area: trailingIcon;
6789
}
6890
6991
&:focus-within {
@@ -84,19 +106,31 @@ const TextInputWrapper = styled.span<StyledWrapperProps>`
84106
background-color: ${get('colors.input.disabledBg')};
85107
border-color: ${get('colors.border.default')};
86108
`}
87-
88-
${props =>
89-
props.block &&
109+
110+
${props =>
111+
props.status === 'error' &&
90112
css`
91-
display: block;
92-
width: 100%;
113+
border-color: ${get('colors.danger.emphasis')};
114+
&:focus-within {
115+
border-color: ${get('colors.danger.emphasis')};
116+
box-shadow: ${get('shadows.btn.danger.focusShadow')};
117+
}
93118
`}
94119
120+
${props =>
121+
props.status === 'warning' &&
122+
css`
123+
border-color: ${get('colors.attention.emphasis')};
124+
&:focus-within {
125+
border-color: ${get('colors.attention.emphasis')};
126+
box-shadow: 0 0 0 3px ${get('colors.attention.muted')};
127+
}
128+
`}
129+
95130
${props =>
96131
props.block &&
97-
props.hasIcon &&
98132
css`
99-
display: flex;
133+
width: 100%;
100134
`}
101135
102136
// Ensures inputs don't zoom on mobile but are body-font size on desktop
@@ -105,9 +139,10 @@ const TextInputWrapper = styled.span<StyledWrapperProps>`
105139
}
106140
${width}
107141
${minWidth}
108-
${maxWidth}
109-
${sizeVariants}
110-
${sx};
142+
${maxWidth}
143+
${sizeDeprecatedVariants}
144+
${sizeVariants}
145+
${sx};
111146
`
112147

113148
export default TextInputWrapper

src/stories/TextInput.stories.tsx

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ export default {
4343
name: 'Variants',
4444
options: ['small', 'medium', 'large'],
4545
control: {type: 'radio'}
46+
},
47+
status: {
48+
name: 'Status',
49+
options: ['warning', 'error'],
50+
control: {type: 'radio'}
51+
},
52+
placeholder: {
53+
name: 'Placeholder',
54+
defaultValue: 'Hello!',
55+
control: {
56+
type: 'text'
57+
}
4658
}
4759
}
4860
} as Meta
@@ -89,7 +101,43 @@ export const WithLeadingIcon = (args: TextInputProps) => {
89101
<form>
90102
<Label htmlFor={inputId}>Example label</Label>
91103
<br />
92-
<TextInput icon={CheckIcon} id={inputId} value={value} onChange={handleChange} type="password" {...args} />
104+
<TextInput leadingIcon={CheckIcon} id={inputId} value={value} onChange={handleChange} {...args} />
105+
</form>
106+
)
107+
}
108+
109+
export const WithTrailingIcon = (args: TextInputProps) => {
110+
const [value, setValue] = useState('')
111+
112+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
113+
setValue(event.target.value)
114+
}
115+
116+
const inputId = 'basic-text-input-with-trailing-icon'
117+
118+
return (
119+
<form>
120+
<Label htmlFor={inputId}>Example label</Label>
121+
<br />
122+
<TextInput trailingIcon={CheckIcon} id={inputId} value={value} onChange={handleChange} {...args} />
123+
</form>
124+
)
125+
}
126+
127+
export const ContrastTextInput = (args: TextInputProps) => {
128+
const [value, setValue] = useState('')
129+
130+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
131+
setValue(event.target.value)
132+
}
133+
134+
const inputId = 'contrast-text-input'
135+
136+
return (
137+
<form>
138+
<Label htmlFor={inputId}>Example label</Label>
139+
<br />
140+
<TextInput contrast id={inputId} value={value} onChange={handleChange} {...args} />
93141
</form>
94142
)
95143
}
@@ -111,3 +159,21 @@ export const Password = (args: TextInputProps) => {
111159
</form>
112160
)
113161
}
162+
163+
export const TextInputInWarningState = (args: TextInputProps) => {
164+
const [value, setValue] = useState('')
165+
166+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
167+
setValue(event.target.value)
168+
}
169+
170+
const inputId = 'text-input-with-warning'
171+
172+
return (
173+
<form>
174+
<Label htmlFor={inputId}>Password</Label>
175+
<br />
176+
<TextInput type="password" id={inputId} value={value} status="warning" {...args} />
177+
</form>
178+
)
179+
}

0 commit comments

Comments
 (0)