Skip to content

feat: Support placeholders in S2 components #8692

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .storybook-s2/docs/Migrating.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ export function Migrating() {
<H3>ColorField</H3>
<ul className="sb-unstyled">
<li className={style({font: 'body', marginY: 8})}>Remove <Code>isQuiet</Code> (it is no longer supported in Spectrum 2)</li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>placeholder</Code> (it has been removed due to accessibility issues)</li>
<li className={style({font: 'body', marginY: 8})}>Change <Code>validationState="invalid"</Code> to <Code>isInvalid</Code></li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>validationState="valid"</Code> (it is no longer supported in Spectrum 2)</li>
</ul>
Expand All @@ -155,7 +154,6 @@ export function Migrating() {
<ul className="sb-unstyled">
<li className={style({font: 'body', marginY: 8})}>Change <Code>menuWidth</Code> value from a <Code>DimensionValue</Code> to a pixel value</li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>isQuiet</Code> (it is no longer supported in Spectrum 2)</li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>placeholder</Code> (it is no longer supported in Spectrum 2)</li>
<li className={style({font: 'body', marginY: 8})}>Change <Code>validationState="invalid"</Code> to <Code>isInvalid</Code></li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>validationState="valid"</Code> (it is no longer supported in Spectrum 2)</li>
<li className={style({font: 'body', marginY: 8})}>Update <Code>Item</Code> to be a <Code>ComboBoxItem</Code></li>
Expand Down Expand Up @@ -338,7 +336,6 @@ export function Migrating() {

<H3>SearchField</H3>
<ul className="sb-unstyled">
<li className={style({font: 'body', marginY: 8})}>Remove <Code>placeholder</Code> (it has been removed due to accessibility issues)</li>
<li className={style({font: 'body', marginY: 8})}>[PENDING] Comment out icon (it has not been implemented yet)</li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>isQuiet</Code> (it is no longer supported in Spectrum 2)</li>
<li className={style({font: 'body', marginY: 8})}>Change <Code>validationState="invalid"</Code> to <Code>isInvalid</Code></li>
Expand Down Expand Up @@ -405,7 +402,6 @@ export function Migrating() {
<ul className="sb-unstyled">
<li className={style({font: 'body', marginY: 8})}>[PENDING] Comment out <Code>icon</Code> (it has not been implemented yet)</li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>isQuiet</Code> (it is no longer supported in Spectrum 2)</li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>placeholder</Code> (it has been removed due to accessibility issues)</li>
<li className={style({font: 'body', marginY: 8})}>Change <Code>validationState="invalid"</Code> to <Code>isInvalid</Code></li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>validationState="valid"</Code> (it is no longer supported in Spectrum 2)</li>
</ul>
Expand All @@ -414,7 +410,6 @@ export function Migrating() {
<ul className="sb-unstyled">
<li className={style({font: 'body', marginY: 8})}>[PENDING] Comment out <Code>icon</Code> (it has not been implemented yet)</li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>isQuiet</Code> (it is no longer supported in Spectrum 2)</li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>placeholder</Code> (it has been removed due to accessibility issues)</li>
<li className={style({font: 'body', marginY: 8})}>Change <Code>validationState="invalid"</Code> to <Code>isInvalid</Code></li>
<li className={style({font: 'body', marginY: 8})}>Remove <Code>validationState="valid"</Code> (it is no longer supported in Spectrum 2)</li>
</ul>
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-spectrum/s2/chromatic/Accordion.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const Example: Story = {
People
</DisclosureTitle>
<DisclosurePanel>
<TextField label="Name" styles={style({maxWidth: 176})} />
<TextField label="Name" styles={style({maxWidth: 176})} placeholder="Enter your name" />
</DisclosurePanel>
</Disclosure>
</Accordion>
Expand Down Expand Up @@ -107,7 +107,7 @@ export const WithDisabledDisclosure: Story = {
People
</DisclosureTitle>
<DisclosurePanel>
<TextField label="Name" />
<TextField label="Name" placeholder="Enter your name" />
</DisclosurePanel>
</Disclosure>
</Accordion>
Expand Down Expand Up @@ -152,7 +152,7 @@ export const WithActionButton: Story = {
<ActionButton><NewIcon aria-label="new icon" /></ActionButton>
</DisclosureHeader>
<DisclosurePanel>
<TextField label="Name" styles={style({maxWidth: 176})} />
<TextField label="Name" styles={style({maxWidth: 176})} placeholder="Enter your name" />
</DisclosurePanel>
</Disclosure>
</Accordion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const Template = ({combos, ...args}: ColorFieldProps & {combos: any[]}): ReactEl
key = 'default';
}
return (
<ColorField data-testid={fullComboName} defaultValue="#e21" label={key} description="test description" errorMessage="test error" {...c} {...args} />
<ColorField data-testid={fullComboName} defaultValue="#e21" label={key} description="test description" errorMessage="test error" placeholder="######" {...c} {...args} />
);
})}
</div>
Expand Down
22 changes: 11 additions & 11 deletions packages/@react-spectrum/s2/chromatic/Forms.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ type Story = StoryObj<typeof Form>;
export const Example: Story = {
render: (args) => (
<Form {...args}>
<TextField label="First Name" name="firstName" />
<TextField label="Last Name" name="firstName" />
<TextField label="Email" name="email" type="email" description="Enter an email" />
<TextField label="First Name" name="firstName" placeholder="John" />
<TextField label="Last Name" name="lastName" placeholder="Doe" />
<TextField label="Email" name="email" type="email" description="Enter an email" placeholder="[email protected]" />
<CheckboxGroup label="Favorite sports">
<Checkbox value="soccer">Soccer</Checkbox>
<Checkbox value="baseball">Baseball</Checkbox>
Expand All @@ -75,10 +75,10 @@ export const Example: Story = {
<Radio value="dog">Dog</Radio>
<Radio value="plant" isDisabled>Plant</Radio>
</RadioGroup>
<TextField label="City" name="city" description="A long description to test help text wrapping." />
<TextField label="A long label to test wrapping behavior" name="long" />
<TextField label="City" name="city" description="A long description to test help text wrapping." placeholder="Some city" />
<TextField label="A long label to test wrapping behavior" name="long" placeholder="looooooooooong" />
<SearchField label="Search" name="search" />
<TextArea label="Comment" name="comment" />
<TextArea label="Comment" name="comment" placeholder="Enter your comment here" />
<Switch>Wi-Fi</Switch>
<Checkbox>I agree to the terms</Checkbox>
<Slider label="Cookies" defaultValue={30} />
Expand All @@ -91,9 +91,9 @@ export const Example: Story = {
export const MixedForm: Story = {
render: (args) => (
<Form {...args}>
<TextField label="First Name" name="firstName" />
<TextField label="Last Name" name="firstName" />
<TextField label="Email" name="email" type="email" description="Enter an email" />
<TextField label="First Name" name="firstName" placeholder="John" />
<TextField label="Last Name" name="lastName" placeholder="Doe" />
<TextField label="Email" name="email" type="email" description="Enter an email" placeholder="[email protected]" />
<CheckboxGroup aria-label="Favorite sports">
<Checkbox value="soccer">Soccer</Checkbox>
<Checkbox value="baseball">Baseball</Checkbox>
Expand Down Expand Up @@ -143,7 +143,7 @@ const CustomLabelsExampleRender = (args: FormProps): ReactElement => {
<ToggleButton>
Enable color
</ToggleButton>
<ColorField aria-label="Fill color" styles={style({width: 144})} />
<ColorField aria-label="Fill color" styles={style({width: 144})} placeholder="######" />
<ColorSlider channel="alpha" defaultValue="#000" />
</div>
<Divider size="S" />
Expand All @@ -152,7 +152,7 @@ const CustomLabelsExampleRender = (args: FormProps): ReactElement => {
<ToggleButton>
Enable search
</ToggleButton>
<TextField aria-label="Query" styles={style({width: 144})} />
<TextField aria-label="Query" styles={style({width: 144})} placeholder="Search here" />
<ComboBox aria-label="Search terms" styles={style({width: 144})}>
<ComboBoxItem>search term 1</ComboBoxItem>
<ComboBoxItem>search term 2</ComboBoxItem>
Expand Down
16 changes: 11 additions & 5 deletions packages/@react-spectrum/s2/chromatic/TextField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ type Story = StoryObj<typeof TextField>;
export const Example: Story = {
render: (args) => <TextField {...args} />,
args: {
label: 'Name'
label: 'Name',
placeholder: 'Enter your name'
}
};

Expand All @@ -57,6 +58,7 @@ export const ContextualHelpExample: Story = {
),
args: {
label: 'Segment',
placeholder: 'Enter your name',
contextualHelp: (
<ContextualHelp>
<Heading>What is a segment?</Heading>
Expand All @@ -80,14 +82,16 @@ export const ContextualHelpExample: Story = {
export const TextAreaExample: StoryObj<typeof TextArea> = {
render: (args) => <TextArea {...args} />,
args: {
label: 'Comment'
label: 'Comment',
placeholder: 'Enter your name'
}
};

export const CustomWidth: Story = {
render: (args) => <TextField {...args} styles={style({width: 384})} />,
args: {
label: 'Name'
label: 'Name',
placeholder: 'Enter your name'
},
parameters: {
docs: {
Expand All @@ -99,7 +103,8 @@ export const CustomWidth: Story = {
export const SmallWidth: Story = {
render: (args) => <TextField {...args} styles={style({width: 48})} />,
args: {
label: 'Name'
label: 'Name',
placeholder: 'Enter your name'
},
parameters: {
docs: {
Expand All @@ -111,7 +116,8 @@ export const SmallWidth: Story = {
export const UNSAFEWidth: Story = {
render: (args) => <TextField {...args} UNSAFE_style={{width: 384}} />,
args: {
label: 'Name'
label: 'Name',
placeholder: 'Enter your name'
},
parameters: {
docs: {
Expand Down
9 changes: 8 additions & 1 deletion packages/@react-spectrum/s2/src/ColorField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {FormContext, useFormProps} from './Form';
import {GlobalDOMAttributes, HelpTextProps, SpectrumLabelableProps} from '@react-types/shared';
import {style} from '../style' with {type: 'macro'};
import {TextFieldRef} from '@react-types/textfield';
import {usePlaceholderWarning} from './placeholder-utils';
import {useSpectrumContextProps} from './useSpectrumContextProps';

export interface ColorFieldProps extends Omit<AriaColorFieldProps, 'children' | 'className' | 'style' | keyof GlobalDOMAttributes>, StyleProps, SpectrumLabelableProps, HelpTextProps {
Expand All @@ -31,7 +32,11 @@ export interface ColorFieldProps extends Omit<AriaColorFieldProps, 'children' |
*
* @default 'M'
*/
size?: 'S' | 'M' | 'L' | 'XL'
size?: 'S' | 'M' | 'L' | 'XL',
/**
* Temporary text that occupies the text input when it is empty.
*/
placeholder?: string
}

export const ColorFieldContext = createContext<ContextValue<Partial<ColorFieldProps>, TextFieldRef>>(null);
Expand Down Expand Up @@ -71,6 +76,8 @@ export const ColorField = forwardRef(function ColorField(props: ColorFieldProps,
}
}));

usePlaceholderWarning(props.placeholder, 'ColorField', inputRef);

return (
<AriaColorField
{...fieldProps}
Expand Down
6 changes: 5 additions & 1 deletion packages/@react-spectrum/s2/src/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ export interface ComboBoxProps<T extends object> extends
/** Width of the menu. By default, matches width of the trigger. Note that the minimum width of the dropdown is always equal to the trigger's width. */
menuWidth?: number,
/** The current loading state of the ComboBox. Determines whether or not the progress circle should be shown. */
loadingState?: LoadingState
loadingState?: LoadingState,
/**
* Temporary text that occupies the text input when it is empty.
*/
placeholder?: string
}

export const ComboBoxContext = createContext<ContextValue<Partial<ComboBoxProps<any>>, TextFieldRef>>(null);
Expand Down
5 changes: 4 additions & 1 deletion packages/@react-spectrum/s2/src/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,10 @@ export const Input = forwardRef(function Input(props: InputProps, ref: Forwarded
className={UNSAFE_className + mergeStyles(style({
padding: 0,
backgroundColor: 'transparent',
color: 'inherit',
color: {
default: 'inherit',
'::placeholder': 'gray-600'
},
fontFamily: 'inherit',
fontSize: 'inherit',
fontWeight: 'inherit',
Expand Down
7 changes: 5 additions & 2 deletions packages/@react-spectrum/s2/src/NumberField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export interface NumberFieldProps extends
*
* @default 'M'
*/
size?: 'S' | 'M' | 'L' | 'XL'
size?: 'S' | 'M' | 'L' | 'XL',
/**
* Temporary text that occupies the text input when it is empty.
*/
placeholder?: string
}

export const NumberFieldContext = createContext<ContextValue<Partial<NumberFieldProps>, TextFieldRef>>(null);
Expand Down Expand Up @@ -173,7 +177,6 @@ export const NumberField = forwardRef(function NumberField(props: NumberFieldPro
}
}));


return (
<AriaNumberField
ref={domRef}
Expand Down
6 changes: 5 additions & 1 deletion packages/@react-spectrum/s2/src/SearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export interface SearchFieldProps extends Omit<AriaSearchFieldProps, 'className'
*
* @default 'M'
*/
size?: 'S' | 'M' | 'L' | 'XL'
size?: 'S' | 'M' | 'L' | 'XL',
/**
* Temporary text that occupies the text input when it is empty.
*/
placeholder?: string
}

export const SearchFieldContext = createContext<ContextValue<Partial<SearchFieldProps>, TextFieldRef>>(null);
Expand Down
23 changes: 17 additions & 6 deletions packages/@react-spectrum/s2/src/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ import {createContext, forwardRef, ReactNode, Ref, useContext, useImperativeHand
import {createFocusableRef} from '@react-spectrum/utils';
import {FieldErrorIcon, FieldGroup, FieldLabel, HelpText, Input} from './Field';
import {FormContext, useFormProps} from './Form';
import {GlobalDOMAttributes, HelpTextProps, SpectrumLabelableProps} from '@react-types/shared';
import {GlobalDOMAttributes, HelpTextProps, RefObject, SpectrumLabelableProps} from '@react-types/shared';
import {mergeRefs} from '@react-aria/utils';
import {style} from '../style' with {type: 'macro'};
import {StyleString} from '../style/types';
import {TextFieldRef} from '@react-types/textfield';
import {usePlaceholderWarning} from './placeholder-utils';
import {useSpectrumContextProps} from './useSpectrumContextProps';

export interface TextFieldProps extends Omit<AriaTextFieldProps, 'children' | 'className' | 'style' | keyof GlobalDOMAttributes>, StyleProps, SpectrumLabelableProps, HelpTextProps {
Expand All @@ -38,7 +39,11 @@ export interface TextFieldProps extends Omit<AriaTextFieldProps, 'children' | 'c
*
* @default 'M'
*/
size?: 'S' | 'M' | 'L' | 'XL'
size?: 'S' | 'M' | 'L' | 'XL',
/**
* Temporary text that occupies the text input when it is empty.
*/
placeholder?: string
}

export const TextFieldContext = createContext<ContextValue<Partial<TextFieldProps>, TextFieldRef>>(null);
Expand Down Expand Up @@ -101,6 +106,8 @@ export const TextFieldBase = forwardRef(function TextFieldBase(props: TextFieldP
...textFieldProps
} = props;

usePlaceholderWarning(props.placeholder, 'TextField/Area', inputRef);

// Expose imperative interface for ref
useImperativeHandle(ref, () => ({
...createFocusableRef(domRef, inputRef),
Expand Down Expand Up @@ -159,7 +166,7 @@ export const TextFieldBase = forwardRef(function TextFieldBase(props: TextFieldP

function TextAreaInput() {
// Force re-render when value changes so we update the height.
useSlottedContext(AriaTextAreaContext) ?? {};
let {placeholder} = useSlottedContext(AriaTextAreaContext) ?? {};
let onHeightChange = (input: HTMLTextAreaElement) => {
// TODO: only do this if an explicit height is not given?
if (input) {
Expand All @@ -180,20 +187,24 @@ function TextAreaInput() {
input.style.alignSelf = prevAlignment;
}
};
let {ref} = useSlottedContext(InputContext) ?? {};

return (
<AriaTextArea
ref={onHeightChange}
ref={mergeRefs(onHeightChange, ref as RefObject<HTMLTextAreaElement | null>)}
// Workaround for baseline alignment bug in Safari.
// https://bugs.webkit.org/show_bug.cgi?id=142968
placeholder=" "
placeholder={placeholder ?? ' '}
className={style({
paddingX: 0,
paddingY: centerPadding(),
minHeight: controlSize(),
boxSizing: 'border-box',
backgroundColor: 'transparent',
color: 'inherit',
color: {
default: 'inherit',
'::placeholder': 'gray-600'
},
fontFamily: 'inherit',
fontSize: 'inherit',
fontWeight: 'inherit',
Expand Down
37 changes: 37 additions & 0 deletions packages/@react-spectrum/s2/src/placeholder-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {getActiveElement, getOwnerDocument, useEffectEvent, useEvent} from '@react-aria/utils';
import {RefObject, useEffect, useRef} from 'react';

export function usePlaceholderWarning(placeholder: string | undefined, componentType: string, inputRef: RefObject<HTMLInputElement | null>): void {
let checkPlaceholder = useEffectEvent((input: HTMLInputElement | null) => {
if (!placeholder && input) {
if (getActiveElement(getOwnerDocument(input)) !== input && (!input.value || input.value === '')) {
console.warn(`Your ${componentType} is empty and not focused but doesn't have a placeholder. Please add one.`);
}
}
});

let hasWarned = useRef(false);
useEffect(() => {
if (!hasWarned.current && process.env.NODE_ENV !== 'production') {
checkPlaceholder(inputRef.current);
}
}, [checkPlaceholder, inputRef, componentType]);

useEvent(inputRef, 'blur', (e) => {
if (!hasWarned.current && process.env.NODE_ENV !== 'production') {
checkPlaceholder(e.target as HTMLInputElement);
}
});
}
Loading