Skip to content

Commit

Permalink
Input: implement primary slot (#20863)
Browse files Browse the repository at this point in the history
  • Loading branch information
ecraig12345 authored Dec 15, 2021
1 parent f71032d commit 58c3108
Show file tree
Hide file tree
Showing 10 changed files with 230 additions and 121 deletions.
26 changes: 11 additions & 15 deletions apps/vr-tests/src/stories/InputConverged.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { Input } from '@fluentui/react-input';
import { Search20Regular, Dismiss20Regular } from '@fluentui/react-icons';
import { TestWrapperDecoratorFixedWidth } from '../utilities/TestWrapperDecorator';

// TODO move input.* props to root once primary slot helper is integrated

storiesOf('Input Converged', module)
.addDecorator(TestWrapperDecoratorFixedWidth)
.addDecorator(story => (
Expand All @@ -23,22 +21,21 @@ storiesOf('Input Converged', module)
{story()}
</Screener>
))
.addStory('Appearance: outline (default)', () => <Input input={{ placeholder: 'Placeholder' }} />)
.addStory('Appearance: outline (default)', () => <Input placeholder="Placeholder" />)
.addStory('Appearance: underline', () => (
<Input appearance="underline" input={{ placeholder: 'Placeholder' }} />
<Input appearance="underline" placeholder="Placeholder" />
))
.addStory('Appearance: filledDarker', () => (
<Input appearance="filledDarker" input={{ placeholder: 'Placeholder' }} />
<Input appearance="filledDarker" placeholder="Placeholder" />
))
.addStory('Appearance: filledLighter', () => (
// filledLighter requires a background to show up (this is colorNeutralBackground3 in web light theme)
<div style={{ background: '#f5f5f5', padding: '10px' }}>
<Input appearance="filledLighter" input={{ placeholder: 'Placeholder' }} />
<Input appearance="filledLighter" placeholder="Placeholder" />
</div>
))
.addStory('Disabled', () => <Input input={{ disabled: true }} />)
// TODO move defaultValue prop to root
.addStory('With value', () => <Input input={{ defaultValue: 'Value!' }} />);
.addStory('Disabled', () => <Input disabled />)
.addStory('With value', () => <Input defaultValue="Value!" />);

// Non-interactive stories
storiesOf('Input Converged', module)
Expand All @@ -48,21 +45,20 @@ storiesOf('Input Converged', module)
{story()}
</Screener>
))
.addStory('Size: small', () => <Input size="small" input={{ placeholder: 'Placeholder' }} />)
.addStory('Size: large', () => <Input size="large" input={{ placeholder: 'Placeholder' }} />)
.addStory('Size: small', () => <Input size="small" placeholder="Placeholder" />)
.addStory('Size: large', () => <Input size="large" placeholder="Placeholder" />)
.addStory('Inline', () => (
<p>
Some text with <Input inline input={{ placeholder: 'hello', style: { width: '75px' } }} />{' '}
inline input
Some text with <Input inline placeholder="hello" style={{ width: '75px' }} /> inline input
</p>
))
.addStory(
'contentBefore',
() => <Input contentBefore={<Search20Regular />} input={{ placeholder: 'Placeholder' }} />,
() => <Input contentBefore={<Search20Regular />} placeholder="Placeholder" />,
{ includeRtl: true },
)
.addStory(
'contentAfter',
() => <Input contentAfter={<Dismiss20Regular />} input={{ placeholder: 'Placeholder' }} />,
() => <Input contentAfter={<Dismiss20Regular />} placeholder="Placeholder" />,
{ includeRtl: true },
);
74 changes: 29 additions & 45 deletions packages/react-input/Spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,83 +67,67 @@ In the future we may implement behavior variants, such as a password field with

In this component, `input` is the primary slot. See notes under [Structure](#structure).

```ts
export type InputProps = InputCommons & Omit<ComponentProps<InputSlots, 'input'>, 'children'>;
```

### Main props

All native HTML `<input>` props are supported. Since the `input` slot is primary (more on that later), top-level native props except `className` and `style` will go to the input.

The top-level `ref` prop also points to the `<input>`. This can be used for things like getting the current value or manipulating focus or selection (instead of explicitly exposing an imperative API).

Most custom props are defined in `InputCommons` since they're shared with `InputState`.
For the full current props, see the types file:
https://github.com/microsoft/fluentui/blob/master/packages/react-input/src/components/Input/Input.types.ts

```ts
export type InputCommons = {
/**
* If true, the field will have inline display, allowing it be used within text content.
* If false (the default), the field will have block display.
*/
// Simplified version of the props (including only summaries of custom props)
type SimplifiedInputProps = {
/** Toggle inline display instead of block */
inline?: boolean;

/**
* Controls the colors and borders of the field.
* @default 'outline'
*/
/** Controls the colors and borders of the field (default `outline`) */
appearance?: 'outline' | 'underline' | 'filledDarker' | 'filledLighter';

/**
* Size of the input (changes the font size and spacing).
* @default 'medium'
*/
/** Size of the input (default `medium`) */
size?: 'small' | 'medium' | 'large';

/** Default value (uncontrolled) */
defaultValue?: string;

/** Controlled value */
value?: string;

/** Called when the user changes the value */
onChange?: (ev: React.FormEvent<HTMLInputElement>, data: { value: string }) => void;
};
```

`size` [overlaps with a native prop](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/size) which sets the width of the field in "number of characters." This isn't ideal, but we're going with it since the native prop isn't very useful in practice, and it was hard to find another reasonable/consistent name for the visual size prop. It's also consistent with the approach used by most other libraries which have a prop for setting the visual size. (If anyone needs the native functionality, we could add an `htmlSize` prop in the future.)
Notes on native prop conflicts/overrides:

- `size` [overlaps with a native prop](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/size) which sets the width of the field in "number of characters." This isn't ideal, but we're going with it since the native prop isn't very useful in practice, and it was hard to find another reasonable/consistent name for the visual size prop. It's also consistent with the approach used by most other libraries which have a prop for setting the visual size. (If anyone needs the native functionality, we could add an `htmlSize` prop in the future.)
- `value` and `defaultValue` are defined in `InputHTMLAttributes` (from `@types/react`) as `string | ReadonlyArray<string> | number` since the same props interface is used for all input element types. To reflect actual usage, we override the types to only accept strings.

### Slots

Note that the field **does not** include a label, required indicator, description, or error message.

```ts
export type InputSlots = {
/**
* Wrapper element which visually appears to be the input and is used for borders, focus styling, etc.
* (A wrapper is needed to properly position `contentBefore` and `contentAfter` relative to `input`.)
*/
root: IntrinsicShorthandProps<'span'>;

/**
* The actual `<input>` element. `type="text"` will be automatically applied unless overridden.
* This is the "primary" slot, so top-level native props (except `className` and `style`) and the
* top-level `ref` will go here.
*/
input: IntrinsicShorthandProps<'input'>;

/** Element before the input text, within the input border */
contentBefore?: IntrinsicShorthandProps<'span'>;

/** Element after the input text, within the input border */
contentAfter?: IntrinsicShorthandProps<'span'>;
};
```
An overview of the slots is as follows. For the current slot types and full docs, see the types file:
https://github.com/microsoft/fluentui/blob/master/packages/react-input/src/components/Input/Input.types.ts

- `root` (`span`): Wrapper which visually appears to be the input (needed to position `contentBefore` and `contentAfter` relative to the actual `input`)
- `input` (`input`, primary slot): The actual text input element
- `contentBefore` (`span`): Element before the input text, within the input border (most often used for icons)
- `contentAfter` (`span`): Element after the input text, within the input border (most often used for icons)

## Structure

In this component, `input` is the primary slot. Per the [native element props/primary slot RFC](https://github.com/microsoft/fluentui/blob/master/rfcs/convergence/native-element-props.md), this means that most top-level props will go to `input`, but the top-level `className` and `style` will go to the actual root element.

```tsx
{
/* Out of top-level native props, only `className` and `style` go here */
}
// Out of top-level native props, only `className` and `style` go here
<slots.root {...slotProps.root}>
<slots.contentBefore {...slotProps.contentBefore} />
{/* Primary slot. Top-level native props except `className` and `style` go here. */}
<slots.input {...slotProps.input} />
<slots.contentAfter {...slotProps.contentAfter} />
</slots.root>;
</slots.root>
```

Notes on the HTML rendering:
Expand Down
25 changes: 14 additions & 11 deletions packages/react-input/etc/react-input.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,38 @@ export const Input: ForwardRefComponent<InputProps>;
// @public (undocumented)
export const inputClassName = "fui-Input";

// @public (undocumented)
export type InputCommons = {
// @public
export type InputOnChangeData = {
value: string;
};

// @public
export type InputProps = Omit<ComponentProps<InputSlots, 'input'>, 'children' | 'defaultValue' | 'onChange' | 'size' | 'value'> & {
children?: never;
size?: 'small' | 'medium' | 'large';
inline?: boolean;
appearance?: 'outline' | 'underline' | 'filledDarker' | 'filledLighter';
defaultValue?: string;
value?: string;
onChange?: (ev: React_2.FormEvent<HTMLInputElement>, data: InputOnChangeData) => void;
};

// @public
export type InputProps = InputCommons & Omit<ComponentProps<InputSlots>, 'children'>;

// @public
export const inputShorthandProps: (keyof InputSlots)[];

// @public (undocumented)
export type InputSlots = {
root: IntrinsicShorthandProps<'span'>;
input: Omit<IntrinsicShorthandProps<'input'>, 'size'>;
input: IntrinsicShorthandProps<'input'>;
contentBefore?: IntrinsicShorthandProps<'span'>;
contentAfter?: IntrinsicShorthandProps<'span'>;
};

// @public
export type InputState = InputCommons & ComponentState<InputSlots>;
export type InputState = Required<Pick<InputProps, 'appearance' | 'inline' | 'size'>> & ComponentState<InputSlots>;

// @public
export const renderInput: (state: InputState) => JSX.Element;

// @public
export const useInput: (props: InputProps, ref: React_2.Ref<HTMLElement>) => InputState;
export const useInput: (props: InputProps, ref: React_2.Ref<HTMLInputElement>) => InputState;

// @public
export const useInputStyles: (state: InputState) => InputState;
Expand Down
19 changes: 3 additions & 16 deletions packages/react-input/src/components/Input/Input.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/// <reference types="@fluentui/react-icons" />
import * as React from 'react';
import { shorthands, makeStyles, mergeClasses } from '@fluentui/react-make-styles';
import { Input } from './Input';
import { getNativeElementProps, useId } from '@fluentui/react-utilities';
import { useId } from '@fluentui/react-utilities';
import { InputProps } from './Input.types';
import { ArgTypes } from '@storybook/react';
import {
Expand All @@ -29,20 +28,10 @@ const icons = {
dismiss: { small: Dismiss16Regular, medium: Dismiss20Regular, large: Dismiss24Regular },
};

export const InputExamples = (
args: Partial<InputProps> & React.InputHTMLAttributes<HTMLInputElement> & { storyFilledBackground: boolean },
) => {
export const InputExamples = (args: InputProps & { storyFilledBackground: boolean }) => {
const styles = useStyles();
const inputId1 = useId();
// pass native input props to the internal input element and custom props to the root
const { storyFilledBackground, ...rest } = args;
const inputProps = getNativeElementProps('input', rest, ['size']);
const props: Partial<InputProps> = { input: inputProps };
for (const prop of Object.keys(rest) as (keyof InputProps)[]) {
if (!(inputProps as Partial<InputProps>)[prop]) {
props[prop] = rest[prop];
}
}
const { storyFilledBackground, ...props } = args;
const SearchIcon = icons.search[props.size!];
const DismissIcon = icons.dismiss[props.size!];

Expand Down Expand Up @@ -90,8 +79,6 @@ const argTypes: ArgTypes = {
},
// this one is for the example
storyFilledBackground: { defaultValue: false, control: { type: 'boolean' } },
// NOTE: these are not actually top-level props right now until RFC is resolved,
// so they get passed through in the example via the input slot
placeholder: { defaultValue: 'placeholder', control: { type: 'text' } },
value: { control: { type: 'text' } },
disabled: { defaultValue: false, control: { type: 'boolean' } },
Expand Down
76 changes: 73 additions & 3 deletions packages/react-input/src/components/Input/Input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,88 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { render, RenderResult, fireEvent, screen } from '@testing-library/react';
import { Input } from './Input';
import { isConformant } from '../../common/isConformant';

function getInput(): HTMLInputElement {
return screen.getByRole('textbox') as HTMLInputElement;
}

describe('Input', () => {
let renderedComponent: RenderResult | undefined;

afterEach(() => {
if (renderedComponent) {
renderedComponent.unmount();
renderedComponent = undefined;
}
});

isConformant({
Component: Input,
displayName: 'Input',
primarySlot: 'input',
});

// TODO add more tests here, and create visual regression tests in /apps/vr-tests

it('renders a default state', () => {
const result = render(<Input />);
expect(result.container).toMatchSnapshot();
});

it('respects value', () => {
renderedComponent = render(<Input value="hello" />);
expect(getInput().value).toEqual('hello');
});

it('respects updates to value', () => {
renderedComponent = render(<Input value="hello" />);
expect(getInput().value).toEqual('hello');

renderedComponent.rerender(<Input value="world" />);
expect(getInput().value).toEqual('world');
});

it('respects defaultValue', () => {
renderedComponent = render(<Input defaultValue="hello" />);
expect(getInput().value).toEqual('hello');
});

it('ignores updates to defaultValue', () => {
renderedComponent = render(<Input defaultValue="hello" />);
expect(getInput().value).toEqual('hello');

renderedComponent.rerender(<Input defaultValue="world" />);
expect(getInput().value).toEqual('hello');
});

it('prefers value over defaultValue', () => {
renderedComponent = render(<Input value="hello" defaultValue="world" />);
expect(getInput().value).toEqual('hello');
});

it('with value, calls onChange but does not update on text entry', () => {
const onChange = jest.fn();
renderedComponent = render(<Input value="hello" onChange={onChange} />);
const input = getInput();
fireEvent.change(input, { target: { value: 'world' } });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][1]).toEqual({ value: 'world' });
expect(input.value).toBe('hello');
});

it('with defaultValue, calls onChange and updates value on text entry', () => {
const onChange = jest.fn();
renderedComponent = render(<Input defaultValue="hello" onChange={onChange} />);
const input = getInput();
fireEvent.change(input, { target: { value: 'world' } });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][1]).toEqual({ value: 'world' });
expect(input.value).toBe('world');
});

it('does not call onChange when value prop updates', () => {
const onChange = jest.fn();
renderedComponent = render(<Input value="hello" onChange={onChange} />);
renderedComponent.rerender(<Input value="world" onChange={onChange} />);
expect(onChange).toHaveBeenCalledTimes(0);
});
});
Loading

0 comments on commit 58c3108

Please sign in to comment.