Skip to content

feat: Textfield prefix#9957

Merged
snowystinger merged 7 commits intomainfrom
textfield-decorations
Apr 30, 2026
Merged

feat: Textfield prefix#9957
snowystinger merged 7 commits intomainfrom
textfield-decorations

Conversation

@snowystinger
Copy link
Copy Markdown
Member

Closes

Adds prefix (and icon) under a new prop, prefix. This is all that Spectrum has defined so far. Eventually we may want to open it to something that can handle more, but probably through a very different API.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

@github-actions github-actions Bot added the S2 label Apr 22, 2026
Comment thread packages/dev/s2-docs/pages/s2/TextArea.mdx
*/
size?: 'S' | 'M' | 'L' | 'XL'
/**
* The prefix to display in the text field. Either a string or workflow icon.
Copy link
Copy Markdown
Member Author

@snowystinger snowystinger Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all the figma currently supports, ie no buttons or loaders. No suffix yet either

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonder if an avatar or something should be supported as well

@snowystinger snowystinger changed the title feat: Textfield decorations feat: Textfield prefix Apr 22, 2026
export const TextFieldWithAddons: StoryTextField = {
render: (args) => (
<Form>
<TextField {...args} label="Phone Number" prefix="#" placeholder="(000) 000-0000" />
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I default add the hash to colorfield?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't it depend what color space and channel the colorfield has? E.g. hsl + hue doesn't have a hash

Copy link
Copy Markdown
Member Author

@snowystinger snowystinger Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Colorfield is always rgb hex, the prop just controls the color object value you get out of it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, but the field should still display a non hash value so we don't want the hash prefix then right? Or am I miss understanding the "default add the hash" part here?

image

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oo right, i forgot to set the channel in addition to the color space, well, we could set it for the ones we know

@rspbot
Copy link
Copy Markdown

rspbot commented Apr 22, 2026

Comment thread packages/@react-spectrum/s2/stories/TextField.stories.tsx Outdated
Comment thread packages/@react-spectrum/s2/src/TextField.tsx
@rspbot
Copy link
Copy Markdown

rspbot commented Apr 22, 2026

@rspbot
Copy link
Copy Markdown

rspbot commented Apr 23, 2026

@rspbot
Copy link
Copy Markdown

rspbot commented Apr 23, 2026

## API Changes

@react-spectrum/s2

/@react-spectrum/s2:TextArea

 TextArea {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-activedescendant?: string
   aria-autocomplete?: 'none' | 'inline' | 'list' | 'both'
   aria-controls?: string
   aria-describedby?: string
   aria-details?: string
   aria-errormessage?: string
   aria-haspopup?: boolean | 'false' | 'true' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoCorrect?: string
   autoFocus?: boolean
   contextualHelp?: ReactNode
   defaultValue?: string
   description?: ReactNode
   enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   inputMode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'
   isDisabled?: boolean
   isInvalid?: boolean
   isReadOnly?: boolean
   isRequired?: boolean
   label?: ReactNode
   labelAlign?: Alignment = 'start'
   labelPosition?: LabelPosition = 'top'
   maxLength?: number
   minLength?: number
   name?: string
   necessityIndicator?: NecessityIndicator = 'icon'
   onBeforeInput?: FormEventHandler<T>
   onBlur?: (FocusEvent<T>) => void
   onChange?: (T) => void
   onCompositionEnd?: CompositionEventHandler<T>
   onCompositionStart?: CompositionEventHandler<T>
   onCompositionUpdate?: CompositionEventHandler<T>
   onCopy?: ClipboardEventHandler<T>
   onCut?: ClipboardEventHandler<T>
   onFocus?: (FocusEvent<T>) => void
   onFocusChange?: (boolean) => void
   onInput?: FormEventHandler<T>
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onPaste?: ClipboardEventHandler<T>
   onSelect?: ReactEventHandler<T>
   placeholder?: string
+  prefix?: ReactNode
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   spellCheck?: string
   styles?: StylesProp
   validationBehavior?: 'native' | 'aria' = 'native'
   value?: string
 }

/@react-spectrum/s2:TextField

 TextField {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-activedescendant?: string
   aria-autocomplete?: 'none' | 'inline' | 'list' | 'both'
   aria-controls?: string
   aria-describedby?: string
   aria-details?: string
   aria-errormessage?: string
   aria-haspopup?: boolean | 'false' | 'true' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoCorrect?: string
   autoFocus?: boolean
   contextualHelp?: ReactNode
   defaultValue?: string
   description?: ReactNode
   enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   inputMode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'
   isDisabled?: boolean
   isInvalid?: boolean
   isReadOnly?: boolean
   isRequired?: boolean
   label?: ReactNode
   labelAlign?: Alignment = 'start'
   labelPosition?: LabelPosition = 'top'
   maxLength?: number
   minLength?: number
   name?: string
   necessityIndicator?: NecessityIndicator = 'icon'
   onBeforeInput?: FormEventHandler<T>
   onBlur?: (FocusEvent<T>) => void
   onChange?: (T) => void
   onCompositionEnd?: CompositionEventHandler<T>
   onCompositionStart?: CompositionEventHandler<T>
   onCompositionUpdate?: CompositionEventHandler<T>
   onCopy?: ClipboardEventHandler<T>
   onCut?: ClipboardEventHandler<T>
   onFocus?: (FocusEvent<T>) => void
   onFocusChange?: (boolean) => void
   onInput?: FormEventHandler<T>
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onPaste?: ClipboardEventHandler<T>
   onSelect?: ReactEventHandler<T>
   pattern?: string
   placeholder?: string
+  prefix?: ReactNode
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   spellCheck?: string
   styles?: StylesProp
   
 }) = 'text'
   validate?: (string) => ValidationError | boolean | null | undefined
   validationBehavior?: 'native' | 'aria' = 'native'
   value?: string
 }

/@react-spectrum/s2:TextFieldProps

 TextFieldProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-activedescendant?: string
   aria-autocomplete?: 'none' | 'inline' | 'list' | 'both'
   aria-controls?: string
   aria-describedby?: string
   aria-details?: string
   aria-errormessage?: string
   aria-haspopup?: boolean | 'false' | 'true' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoCorrect?: string
   autoFocus?: boolean
   contextualHelp?: ReactNode
   defaultValue?: string
   description?: ReactNode
   enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   inputMode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'
   isDisabled?: boolean
   isInvalid?: boolean
   isReadOnly?: boolean
   isRequired?: boolean
   label?: ReactNode
   labelAlign?: Alignment = 'start'
   labelPosition?: LabelPosition = 'top'
   maxLength?: number
   minLength?: number
   name?: string
   necessityIndicator?: NecessityIndicator = 'icon'
   onBeforeInput?: FormEventHandler<T>
   onBlur?: (FocusEvent<T>) => void
   onChange?: (T) => void
   onCompositionEnd?: CompositionEventHandler<T>
   onCompositionStart?: CompositionEventHandler<T>
   onCompositionUpdate?: CompositionEventHandler<T>
   onCopy?: ClipboardEventHandler<T>
   onCut?: ClipboardEventHandler<T>
   onFocus?: (FocusEvent<T>) => void
   onFocusChange?: (boolean) => void
   onInput?: FormEventHandler<T>
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onPaste?: ClipboardEventHandler<T>
   onSelect?: ReactEventHandler<T>
   pattern?: string
   placeholder?: string
+  prefix?: ReactNode
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   spellCheck?: string
   styles?: StylesProp
   
 }) = 'text'
   validate?: (string) => ValidationError | boolean | null | undefined
   validationBehavior?: 'native' | 'aria' = 'native'
   value?: string
 }

/@react-spectrum/s2:TextAreaProps

 TextAreaProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-activedescendant?: string
   aria-autocomplete?: 'none' | 'inline' | 'list' | 'both'
   aria-controls?: string
   aria-describedby?: string
   aria-details?: string
   aria-errormessage?: string
   aria-haspopup?: boolean | 'false' | 'true' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoCorrect?: string
   autoFocus?: boolean
   contextualHelp?: ReactNode
   defaultValue?: string
   description?: ReactNode
   enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   inputMode?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'
   isDisabled?: boolean
   isInvalid?: boolean
   isReadOnly?: boolean
   isRequired?: boolean
   label?: ReactNode
   labelAlign?: Alignment = 'start'
   labelPosition?: LabelPosition = 'top'
   maxLength?: number
   minLength?: number
   name?: string
   necessityIndicator?: NecessityIndicator = 'icon'
   onBeforeInput?: FormEventHandler<T>
   onBlur?: (FocusEvent<T>) => void
   onChange?: (T) => void
   onCompositionEnd?: CompositionEventHandler<T>
   onCompositionStart?: CompositionEventHandler<T>
   onCompositionUpdate?: CompositionEventHandler<T>
   onCopy?: ClipboardEventHandler<T>
   onCut?: ClipboardEventHandler<T>
   onFocus?: (FocusEvent<T>) => void
   onFocusChange?: (boolean) => void
   onInput?: FormEventHandler<T>
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onPaste?: ClipboardEventHandler<T>
   onSelect?: ReactEventHandler<T>
   placeholder?: string
+  prefix?: ReactNode
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   spellCheck?: string
   styles?: StylesProp
   validationBehavior?: 'native' | 'aria' = 'native'
   value?: string
 }

Comment thread packages/dev/s2-docs/pages/s2/TextArea.mdx
render: (args) => (
<Form>
<TextField {...args} label="Phone Number" prefix="#" placeholder="(000) 000-0000" />
<TextField {...args} label="URL" prefix="https://" placeholder="example.com" />
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it weird that the prefix text is not quite at the same level as the placeholder text?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it not? it looks like it's at the same level to me?
Screenshot 2026-04-30 at 10 54 22 am

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm I see this in the docs via https://d1pzu54gtk2aed.cloudfront.net/pr/a670e569138a9388c25d2ae0acbd545c3e5b6050/TextField:
image

I swear I saw it in the storybook too, but doesn't seem like it anymore

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying it in the docs, I cannot reproduce. What browser are you in and what version?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chrome 147.0.7727.117, MacOS Tahoe. If you can't reproduce and are on the same version then happy to approve and we can test it further in testing

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried on 147.0.7727.102 and 147.0.7727.138 on MacOS Tahoe, but I'm not seeing it in Chrome

I tried Firefox and Safari as well. I only see the issue in Safari.

I can't see any reason this should happen, it could just be a difference in how inputs render. I tried adjusting line-height to match, but I'm worried that's a bigger change than I want to tackle here since it was needed inside Field.tsx. I also tried removing flex and using vertical align baseline with no success.

It looks like it's .5px, so I'm going to recommend we continue and we can try to fix it later.

export const TextFieldWithAddons: StoryTextField = {
render: (args) => (
<Form>
<TextField {...args} label="Phone Number" prefix="#" placeholder="(000) 000-0000" />
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't it depend what color space and channel the colorfield has? E.g. hsl + hue doesn't have a hash

*/
size?: 'S' | 'M' | 'L' | 'XL'
/**
* The prefix to display in the text field. Either a string or workflow icon.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonder if an avatar or something should be supported as well

@snowystinger
Copy link
Copy Markdown
Member Author

wonder if an avatar or something should be supported as well

For the moment no, but sounds like in the future, possibly along with swatches

@snowystinger
Copy link
Copy Markdown
Member Author

snowystinger commented Apr 30, 2026

Combobox was also discussed, but it has open questions. I've opened it as a follow up #9999

We can do ColorField there too.

@snowystinger snowystinger added this pull request to the merge queue Apr 30, 2026
Merged via the queue into main with commit 6239c7e Apr 30, 2026
29 checks passed
@snowystinger snowystinger deleted the textfield-decorations branch April 30, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants