Skip to content

Commit abb4520

Browse files
feat: Allow positional placement of endSlot in TextField (#899)
* chore: Allow npm v11+ * feat: Allow positional placement of `endSlot` in `TextField` * chore: Update comment * chore: Correct type usage * chore: Fix endslot visibility * test: Add tests for `endSlotPosition`
1 parent 9eea891 commit abb4520

File tree

8 files changed

+140
-32
lines changed

8 files changed

+140
-32
lines changed

package-lock.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
],
3030
"engines": {
3131
"node": "^16.0.0 || ^18.0.0 || ^20.0.0 || ^21.0.0",
32-
"npm": "^8.3.0 || ^9.0.0 || ^10.0.0"
32+
"npm": "^8.3.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
3333
},
3434
"scripts": {
3535
"postinstall": "patch-package",

src/base-field/base-field.tsx

+48-22
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,23 @@ export type BaseFieldProps = WithEnhancedClassName &
213213
* @default 'below'
214214
*/
215215
characterCountPosition?: 'below' | 'inline' | 'hidden'
216-
}
216+
} & (
217+
| {
218+
supportsStartAndEndSlots?: false
219+
endSlot?: never
220+
endSlotPosition?: never
221+
}
222+
| {
223+
supportsStartAndEndSlots: true
224+
endSlot?: React.ReactElement | string | number
225+
/**
226+
* This is solely for `bordered` variants of TextField. When set to `bottom` (the default),
227+
* the endSlot will be placed inline with the input field. When set to `fullHeight`, the endSlot
228+
* will be placed to the side of both the input field and the label.
229+
*/
230+
endSlotPosition?: 'bottom' | 'fullHeight'
231+
}
232+
)
217233

218234
type FieldComponentProps<T extends HTMLElement> = Omit<
219235
BaseFieldProps,
@@ -239,6 +255,8 @@ function BaseField({
239255
'aria-describedby': originalAriaDescribedBy,
240256
id: originalId,
241257
characterCountPosition = 'below',
258+
endSlot,
259+
endSlotPosition = 'bottom',
242260
}: BaseFieldProps & BaseFieldVariantProps & WithEnhancedClassName) {
243261
const id = useId(originalId)
244262
const messageId = useId()
@@ -302,36 +320,44 @@ function BaseField({
302320
return (
303321
<Stack space="xsmall" hidden={hidden}>
304322
<Box
323+
display="flex"
324+
flexDirection="row"
305325
className={[
306326
className,
307327
styles.container,
308328
tone === 'error' ? styles.error : null,
309329
variant === 'bordered' ? styles.bordered : null,
310330
]}
311331
maxWidth={maxWidth}
332+
alignItems="center"
312333
>
313-
{label || auxiliaryLabel ? (
314-
<Box
315-
as="span"
316-
display="flex"
317-
justifyContent="spaceBetween"
318-
alignItems="flexEnd"
319-
>
320-
<Text
321-
size={variant === 'bordered' ? 'caption' : 'body'}
322-
as="label"
323-
htmlFor={id}
334+
<Box flexGrow={1}>
335+
{label || auxiliaryLabel ? (
336+
<Box
337+
as="span"
338+
display="flex"
339+
justifyContent="spaceBetween"
340+
alignItems="flexEnd"
324341
>
325-
{label ? <span className={styles.primaryLabel}>{label}</span> : null}
326-
</Text>
327-
{auxiliaryLabel ? (
328-
<Box className={styles.auxiliaryLabel} paddingLeft="small">
329-
{auxiliaryLabel}
330-
</Box>
331-
) : null}
332-
</Box>
333-
) : null}
334-
{children(childrenProps)}
342+
<Text
343+
size={variant === 'bordered' ? 'caption' : 'body'}
344+
as="label"
345+
htmlFor={id}
346+
>
347+
{label ? (
348+
<span className={styles.primaryLabel}>{label}</span>
349+
) : null}
350+
</Text>
351+
{auxiliaryLabel ? (
352+
<Box className={styles.auxiliaryLabel} paddingLeft="small">
353+
{auxiliaryLabel}
354+
</Box>
355+
) : null}
356+
</Box>
357+
) : null}
358+
{children(childrenProps)}
359+
</Box>
360+
{endSlot && endSlotPosition === 'fullHeight' ? endSlot : null}
335361
</Box>
336362

337363
{message || characterCount ? (

src/select-field/select-field.tsx

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import { Box } from '../box'
44
import styles from './select-field.module.css'
55

66
interface SelectFieldProps
7-
extends Omit<FieldComponentProps<HTMLSelectElement>, 'maxLength' | 'characterCountPosition'>,
7+
extends Omit<
8+
FieldComponentProps<HTMLSelectElement>,
9+
| 'maxLength'
10+
| 'characterCountPosition'
11+
| 'endSlot'
12+
| 'supportsStartAndEndSlots'
13+
| 'endSlotPosition'
14+
>,
815
BaseFieldVariantProps {}
916

1017
const SelectField = React.forwardRef<HTMLSelectElement, SelectFieldProps>(function SelectField(

src/text-area/text-area.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { BaseField, BaseFieldVariantProps, FieldComponentProps } from '../base-f
55
import { Box } from '../box'
66
import styles from './text-area.module.css'
77

8-
interface TextAreaProps extends FieldComponentProps<HTMLTextAreaElement>, BaseFieldVariantProps {
8+
interface TextAreaProps
9+
extends FieldComponentProps<HTMLTextAreaElement>,
10+
Omit<BaseFieldVariantProps, 'supportsStartAndEndSlots' | 'endSlot' | 'endSlotPosition'> {
911
/**
1012
* The number of visible text lines for the text area.
1113
*

src/text-field/text-field.stories.mdx

+34-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { TextField } from './'
44
import { Box } from '../box'
55
import { Stack } from '../stack'
66
import { Text } from '../text'
7-
import { Button } from '../button'
7+
import { Button, IconButton } from '../button'
88
import { Tooltip } from '../tooltip'
99

1010
import { selectWithNone } from '../utils/storybook-helper'
@@ -256,7 +256,7 @@ export function ClearButtonIcon() {
256256
export function ClearButtonExample({ slot }) {
257257
const [value, setValue] = React.useState('')
258258
const clearButton = (
259-
<Button
259+
<IconButton
260260
variant="quaternary"
261261
icon={<ClearButtonIcon />}
262262
aria-label="Clear search"
@@ -325,6 +325,38 @@ Hence, the description is never needed when the field is not focused anyway.
325325
</Story>
326326
</Canvas>
327327

328+
## Variants
329+
330+
The `variant` prop is used to change the style of the text field. The default variant is `default`. The other variant is `bordered`.
331+
332+
export function WithBorderedExample() {
333+
return (
334+
<TextField label="Company name" placeholder="Text field with a border" variant="bordered" />
335+
)
336+
}
337+
338+
export function WithBorderedAndEndSlotExample({ endSlotPosition = 'fullHeight' } = {}) {
339+
return (
340+
<TextField
341+
label="Company name"
342+
placeholder="Text field with a border and end slot"
343+
variant="bordered"
344+
endSlot={<IconButton variant="primary" icon="😄" aria-label="Say cheese!" />}
345+
endSlotPosition={endSlotPosition}
346+
/>
347+
)
348+
}
349+
350+
<Canvas withToolbar>
351+
<Story name="Bordered variant">
352+
<Stack space="xxlarge" dividers="secondary">
353+
<WithBorderedExample />
354+
<WithBorderedAndEndSlotExample />
355+
<WithBorderedAndEndSlotExample endSlotPosition="bottom" />
356+
</Stack>
357+
</Story>
358+
</Canvas>
359+
328360
## Max length
329361

330362
The `maxLength` prop is used to limit the number of characters that the user can input. When the user tries to input more characters than the maximum allowed, the field won't accept the input.

src/text-field/text-field.test.tsx

+34-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react'
22
import { render, screen } from '@testing-library/react'
3-
import { TextField } from './'
3+
import { TextField, TextFieldProps } from './'
44
import userEvent from '@testing-library/user-event'
55
import { axe } from 'jest-axe'
66

@@ -247,6 +247,39 @@ describe('TextField', () => {
247247
})
248248
})
249249

250+
describe('endSlotPosition', () => {
251+
test.each<TextFieldProps['endSlotPosition']>(['bottom', 'fullHeight', undefined])(
252+
'renders the end slot for default variant when endSlotPosition is %s',
253+
(endSlotPosition) => {
254+
render(
255+
<TextField
256+
label="Whatʼs your name?"
257+
maxLength={30}
258+
endSlot="Kwijibo"
259+
endSlotPosition={endSlotPosition}
260+
/>,
261+
)
262+
expect(screen.getByText('Kwijibo')).toBeInTheDocument()
263+
},
264+
)
265+
266+
test.each<TextFieldProps['endSlotPosition']>(['bottom', 'fullHeight', undefined])(
267+
'renders the end slot for bordered variant when endSlotPosition is %s',
268+
(endSlotPosition) => {
269+
render(
270+
<TextField
271+
label="Whatʼs your name?"
272+
maxLength={30}
273+
endSlot="Kwijibo"
274+
endSlotPosition={endSlotPosition}
275+
variant="bordered"
276+
/>,
277+
)
278+
expect(screen.getByText('Kwijibo')).toBeInTheDocument()
279+
},
280+
)
281+
})
282+
250283
describe('character count', () => {
251284
it('renders the character count element when characterCountPosition is "below"', () => {
252285
render(

src/text-field/text-field.tsx

+11-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { useMergeRefs } from 'use-callback-ref'
88
type TextFieldType = 'email' | 'search' | 'tel' | 'text' | 'url'
99

1010
interface TextFieldProps
11-
extends Omit<FieldComponentProps<HTMLInputElement>, 'type'>,
11+
extends Omit<FieldComponentProps<HTMLInputElement>, 'type' | 'supportsStartAndEndSlots'>,
1212
BaseFieldVariantProps,
1313
Pick<BaseFieldProps, 'characterCountPosition'> {
1414
type?: TextFieldType
@@ -40,6 +40,7 @@ const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(function Te
4040
endSlot,
4141
onChange: originalOnChange,
4242
characterCountPosition = 'below',
43+
endSlotPosition = 'bottom',
4344
...props
4445
},
4546
ref,
@@ -52,6 +53,10 @@ const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(function Te
5253
internalRef.current?.focus()
5354
}
5455

56+
const displayEndSlot =
57+
endSlot &&
58+
(variant === 'default' || (variant === 'bordered' && endSlotPosition === 'bottom'))
59+
5560
return (
5661
<BaseField
5762
variant={variant}
@@ -66,6 +71,9 @@ const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(function Te
6671
hidden={hidden}
6772
aria-describedby={ariaDescribedBy}
6873
characterCountPosition={characterCountPosition}
74+
supportsStartAndEndSlots
75+
endSlot={endSlot}
76+
endSlotPosition={variant === 'bordered' ? endSlotPosition : undefined}
6977
>
7078
{({ onChange, characterCountElement, ...extraProps }) => (
7179
<Box
@@ -100,15 +108,15 @@ const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(function Te
100108
onChange?.(event)
101109
}}
102110
/>
103-
{endSlot || characterCountElement ? (
111+
{displayEndSlot || characterCountElement ? (
104112
<Box
105113
className={styles.slot}
106114
display="flex"
107115
marginRight={variant === 'bordered' ? '-xsmall' : 'xsmall'}
108116
marginLeft={variant === 'bordered' ? 'xsmall' : '-xsmall'}
109117
>
110118
{characterCountElement}
111-
{endSlot}
119+
{displayEndSlot ? endSlot : null}
112120
</Box>
113121
) : null}
114122
</Box>

0 commit comments

Comments
 (0)