Skip to content

[WIP]: S2 SearchField minimized prototype #8136

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

Closed
wants to merge 12 commits into from
5 changes: 3 additions & 2 deletions packages/@react-spectrum/s2/src/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,11 @@ const fieldGroupStyles = style({
});

export const FieldGroup = forwardRef(function FieldGroup(props: FieldGroupProps, ref: ForwardedRef<HTMLDivElement>) {
let {UNSAFE_style, ...rest} = props;
return (
<Group
ref={ref}
{...props}
{...rest}
onPointerDown={(e) => {
// Forward focus to input element when clicking on a non-interactive child (e.g. icon or padding)
if (e.pointerType === 'mouse' && !(e.target as Element).closest('button,input,textarea')) {
Expand All @@ -210,7 +211,7 @@ export const FieldGroup = forwardRef(function FieldGroup(props: FieldGroupProps,
e.currentTarget.querySelector('input')?.focus();
}
}}
style={props.UNSAFE_style}
style={UNSAFE_style}
className={renderProps => (props.UNSAFE_className || '') + ' ' + centerBaselineBefore + mergeStyles(
fieldGroupStyles({...renderProps, size: props.size || 'M'}),
props.styles
Expand Down
211 changes: 137 additions & 74 deletions packages/@react-spectrum/s2/src/SearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/

import {ActionButton} from './ActionButton';
import {
SearchField as AriaSearchField,
SearchFieldProps as AriaSearchFieldProps,
Expand All @@ -18,17 +19,20 @@ import {
} from 'react-aria-components';
import {centerBaseline} from './CenterBaseline';
import {ClearButton} from './ClearButton';
import {createContext, forwardRef, Ref, useContext, useImperativeHandle, useRef} from 'react';
import {createContext, forwardRef, Ref, useContext, useEffect, useImperativeHandle, useRef} from 'react';
import {createFocusableRef} from '@react-spectrum/utils';
import {field, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'};
import {FieldGroup, FieldLabel, HelpText, Input} from './Field';
import {fontRelative, style} from '../style' with {type: 'macro'};
import {FormContext, useFormProps} from './Form';
import {HelpTextProps, SpectrumLabelableProps} from '@react-types/shared';
import {IconContext} from './Icon';
import {mergeProps} from 'react-aria';
import {raw} from '../style/style-macro' with {type: 'macro'};
import SearchIcon from '../s2wf-icons/S2_Icon_Search_20_N.svg';
import {TextFieldRef} from '@react-types/textfield';
import {useControlledState} from '@react-stately/utils';
import {useFocus} from '@react-aria/interactions';
import {useSpectrumContextProps} from './useSpectrumContextProps';

export interface SearchFieldProps extends Omit<AriaSearchFieldProps, 'className' | 'style' | 'children'>, StyleProps, SpectrumLabelableProps, HelpTextProps {
Expand All @@ -37,7 +41,21 @@ export interface SearchFieldProps extends Omit<AriaSearchFieldProps, 'className'
*
* @default 'M'
*/
size?: 'S' | 'M' | 'L' | 'XL'
size?: 'S' | 'M' | 'L' | 'XL',
/** Whether the search field is minimized (controlled). */
isMinimized?: boolean,
/**
* Whether the search field is minimized by default (uncontrolled).
*
* @default false
*/
defaultMinimized?: boolean,
/** Handler that is called when the minimized state changes. */
onMinimizeChange?: (isMinimized: boolean) => void,
/** The duration of the transition animation (for testing only). */
transitionDuration?: number,
/** The timing function of the transition animation (for testing only). */
transitionTimingFunction?: string
}

export const SearchFieldContext = createContext<ContextValue<Partial<SearchFieldProps>, TextFieldRef>>(null);
Expand All @@ -58,12 +76,33 @@ export const SearchField = /*#__PURE__*/ forwardRef(function SearchField(props:
labelAlign = 'start',
UNSAFE_className = '',
UNSAFE_style,
isMinimized: isMinimizedProp,
defaultMinimized = false,
onMinimizeChange,
...searchFieldProps
} = props;

let [isMinimized, setIsMinimized] = useControlledState(isMinimizedProp, defaultMinimized, onMinimizeChange);

let domRef = useRef<HTMLDivElement>(null);
let inputRef = useRef<HTMLInputElement>(null);

// Focus the input when the field is expanded.
useEffect(() => {
if (!isMinimized && inputRef.current) {
inputRef.current.focus();
}
}, [isMinimized]);

let {focusProps} = useFocus({
onBlur: () => {
// Minimize the field when it loses focus and is empty (if minimization is enabled)
if ((isMinimizedProp !== undefined || defaultMinimized) && inputRef.current?.value === '') {
setIsMinimized(true);
}
}
});

// Expose imperative interface for ref
useImperativeHandle(ref, () => ({
...createFocusableRef(domRef, inputRef),
Expand All @@ -78,80 +117,104 @@ export const SearchField = /*#__PURE__*/ forwardRef(function SearchField(props:
}));

return (
<AriaSearchField
{...searchFieldProps}
ref={domRef}
style={UNSAFE_style}
className={UNSAFE_className + style({
...field(),
'--iconMargin': {
type: 'marginTop',
value: fontRelative(-2)
},
color: {
default: 'neutral',
isDisabled: {
default: 'disabled',
forcedColors: 'GrayText'
<>
<ActionButton
styles={style({marginStart: '[10px]', position: 'absolute'})}
UNSAFE_style={{
top: 0,
left: 0,
visibility: isMinimized ? 'visible' : 'hidden',
transition: `visibility ${props.transitionDuration || 200}ms ${props.transitionTimingFunction || 'ease'}`
}}
aria-label={typeof label === 'string' ? label : props['aria-label'] || 'Search'}
size={props.size}
isQuiet
isDisabled={props.isDisabled}
onPress={() => setIsMinimized(false)}>
<SearchIcon />
</ActionButton>
<AriaSearchField
{...mergeProps(searchFieldProps, focusProps)}
ref={domRef}
style={UNSAFE_style}
className={UNSAFE_className + style({
...field(),
'--iconMargin': {
type: 'marginTop',
value: fontRelative(-2)
},
color: {
default: 'neutral',
isDisabled: {
default: 'disabled',
forcedColors: 'GrayText'
}
}
}
}, getAllowedOverrides())({
size: props.size,
labelPosition,
isInForm: !!formContext
}, props.styles)}>
{({isDisabled, isInvalid, isEmpty}) => (<>
{label && <FieldLabel
isDisabled={isDisabled}
isRequired={props.isRequired}
size={props.size}
labelPosition={labelPosition}
labelAlign={labelAlign}
necessityIndicator={necessityIndicator}
contextualHelp={props.contextualHelp}>
{label}
</FieldLabel>}
<FieldGroup
isDisabled={isDisabled}
size={props.size}
styles={style({
borderRadius: 'full',
paddingStart: 'pill',
paddingEnd: 0
})}>
<Provider
values={[
[IconContext, {
render: centerBaseline({
slot: 'icon',
}, getAllowedOverrides())({
size: props.size,
labelPosition,
isInForm: !!formContext
}, props.styles)}>
{({isDisabled, isInvalid, isEmpty}) => (<>
{label && <FieldLabel
isDisabled={isDisabled}
isRequired={props.isRequired}
size={props.size}
labelPosition={labelPosition}
labelAlign={labelAlign}
necessityIndicator={necessityIndicator}
contextualHelp={props.contextualHelp}>
{label}
</FieldLabel>}
<FieldGroup
isDisabled={isDisabled}
size={props.size}
UNSAFE_style={{
transition: `width ${props.transitionDuration || 200}ms ${props.transitionTimingFunction || 'ease'}, opacity ${props.transitionDuration || 200}ms ${props.transitionTimingFunction || 'ease'}`,
width: isMinimized ? 0 : '100%',
opacity: isMinimized ? 0 : 1,
visibility: isMinimized ? 'hidden' : 'visible'
}}
styles={style({
borderRadius: 'full',
paddingStart: 'pill',
paddingEnd: 0
})}>
<Provider
values={[
[IconContext, {
render: centerBaseline({
slot: 'icon',
styles: style({
flexShrink: 0,
marginEnd: 'text-to-visual',
'--iconPrimary': {
type: 'fill',
value: 'currentColor'
}
})
}),
styles: style({
flexShrink: 0,
marginEnd: 'text-to-visual',
'--iconPrimary': {
type: 'fill',
value: 'currentColor'
}
size: fontRelative(20),
marginStart: '--iconMargin',
opacity: 1
})
}),
styles: style({
size: fontRelative(20),
marginStart: '--iconMargin'
})
}]
]}>
<SearchIcon />
</Provider>
<Input ref={inputRef} UNSAFE_className={raw('&::-webkit-search-cancel-button { display: none }')} />
{!isEmpty && !searchFieldProps.isReadOnly && <ClearButton size={props.size} />}
</FieldGroup>
<HelpText
size={props.size}
isDisabled={isDisabled}
isInvalid={isInvalid}
description={description}>
{errorMessage}
</HelpText>
</>)}
</AriaSearchField>
}]
]}>
<SearchIcon />
</Provider>
<Input ref={inputRef} UNSAFE_className={raw('&::-webkit-search-cancel-button { display: none }')} />
{!isEmpty && !searchFieldProps.isReadOnly && <ClearButton size={props.size} />}
</FieldGroup>
<HelpText
size={props.size}
isDisabled={isDisabled}
isInvalid={isInvalid}
description={description}>
{errorMessage}
</HelpText>
</>)}
</AriaSearchField>
</>
);
});
26 changes: 25 additions & 1 deletion packages/@react-spectrum/s2/stories/SearchField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@ const meta: Meta<typeof SearchField> = {
},
tags: ['autodocs'],
argTypes: {
...categorizeArgTypes('Events', ['onChange', 'onClear', 'onSubmit'])
...categorizeArgTypes('Events', ['onChange', 'onClear', 'onSubmit']),
transitionDuration: {
control: 'number',
defaultValue: 200
},
transitionTimingFunction: {
control: 'select',
options: ['ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear']
}
},
title: 'SearchField'
};
Expand Down Expand Up @@ -78,3 +86,19 @@ export const ContextualHelpExample = (args: any) => (
ContextualHelpExample.args = {
label: 'Search'
};

export const Minimized = {
render: (args: any) => <SearchField {...args} />,
args: {
label: null,
ariaLabel: 'Search',
defaultMinimized: true
},
decorators: [
(Story) => (
<div style={{width: '200px', position: 'relative'}}>
<Story />
</div>
)
]
};