Skip to content

Commit

Permalink
feat(text-input): Add InputGroup component (#2182)
Browse files Browse the repository at this point in the history
Add an `InputGroup` component to handle icons in a `TextInput` component.

There is already an `InputIconContainer` component that is not documented, but is limited to only right-aligned icons and doesn't handle RTL.

[category:Components]

Release Note:
`InputGroup` will replace `InputIconContainer`. `InputIconContainer` does not handle bidirectionality or icons at the start of an input. `InputIconContainer` will be deprecated and later removed in future versions.

Before:
```tsx
<InputIconContainer icon={<SystemIcon icon={exclamationCircleIcon} />} />
```

After
```tsx
<InputGroup>
  <InputGroup.Input />
  <InputGroup.InnerEnd>
    <SystemIcon icon={exclamationCircleIcon} />
  </InputGroup.InnerEnd>
</InputGroup>
```
  • Loading branch information
NicholasBoll committed May 2, 2023
1 parent 75acd35 commit 2b77c46
Show file tree
Hide file tree
Showing 9 changed files with 374 additions and 2 deletions.
2 changes: 1 addition & 1 deletion modules/react/common/lib/utils/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ type RemoveNull<T> = {[K in keyof T]: Exclude<T[K], null>};
*/
type CompoundProps<Props, TElemPropsHook, E> = Props &
(TElemPropsHook extends (...args: any[]) => infer TProps // try to infer TProps returned from the elemPropsHook function
? RemoveNull<TProps extends {ref: any} ? TProps : TProps & {ref: ExtractRef<E>}>
? RemoveNull<Omit<TProps, 'ref'> & {ref: ExtractRef<E>}>
: {ref: ExtractRef<E>}) &
(Props extends {children: any}
? {}
Expand Down
16 changes: 16 additions & 0 deletions modules/react/common/lib/utils/dispatchInputEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function dispatchInputEvent<T extends HTMLElement>(input: T | null, value: string): void {
// Changing value prop programmatically doesn't fire a synthetic event or trigger a native
// onChange event. We can not just update .value= in setState because React library overrides
// input value setter but we can call the function directly on the input as context. This will
// cause onChange events to fire no matter how value is updated.
if (input) {
const nativeInputValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), 'value');
if (nativeInputValue && nativeInputValue.set) {
nativeInputValue.set.call(input, value);
}

const event = new Event('input', {bubbles: true});

input.dispatchEvent(event);
}
}
1 change: 1 addition & 0 deletions modules/react/common/lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './colorUtils';
export * from './getTranslateFromOrigin';
export * from './dispatchInputEvent';
export * from './changeFocus';
export * from './makeMq';
export * from './mergeCallback';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
StaticStates,
} from '@workday/canvas-kit-react/testing';
import {withSnapshotsEnabled, customColorTheme} from '../../../../../utils/storybook';
import {TextInput} from '@workday/canvas-kit-react/text-input';
import {InputGroup, TextInput} from '@workday/canvas-kit-react/text-input';
import {searchIcon, xSmallIcon} from '@workday/canvas-system-icons-web';
import {SystemIcon} from '@workday/canvas-kit-react/icon';
import {TertiaryButton} from '@workday/canvas-kit-react/button';
import {CanvasProvider} from '@workday/canvas-kit-react/common';

export default withSnapshotsEnabled({
title: 'Testing/Inputs/Text Input',
Expand Down Expand Up @@ -76,3 +80,57 @@ TextInputThemedStates.parameters = {
theme: customColorTheme,
},
};

export const InputGroupStates = () => (
<StaticStates>
<ComponentStatesTable
rowProps={[
{label: 'Start', props: {start: [<SystemIcon icon={searchIcon} size="small" />]}},
{
label: 'End',
props: {
end: [
<TertiaryButton role="presentation" icon={xSmallIcon} size="small" tabIndex={-1} />,
],
},
},
{
label: 'Both',
props: {
start: [<SystemIcon icon={searchIcon} size="small" />],
end: [
<TertiaryButton role="presentation" icon={xSmallIcon} size="small" tabIndex={-1} />,
],
},
},
{
label: 'Multiple',
props: {
start: [<span>1</span>, <span>2</span>, <span>3</span>],
end: [<span>4</span>, <span>5</span>, <span>6</span>],
},
},
]}
columnProps={[
{label: 'LTR', props: {dir: 'ltr'}},
{label: 'RTL', props: {dir: 'rtl'}},
]}
>
{props => (
<CanvasProvider theme={{canvas: {direction: props.dir}}}>
<InputGroup width={300}>
{props.start &&
props.start.map((start, index) => (
<InputGroup.InnerStart key={index}>{start}</InputGroup.InnerStart>
))}
<InputGroup.Input value="Very Long Text. Very Long Text. Very Long Text." />
{props.end &&
props.end.map((end, index) => (
<InputGroup.InnerEnd key={index}>{end}</InputGroup.InnerEnd>
))}
</InputGroup>
</CanvasProvider>
)}
</ComponentStatesTable>
</StaticStates>
);
1 change: 1 addition & 0 deletions modules/react/text-input/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './lib/InputIconContainer';
export * from './lib/TextInput';
export {InputGroup} from './lib/InputGroup';
168 changes: 168 additions & 0 deletions modules/react/text-input/lib/InputGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React from 'react';

import {space} from '@workday/canvas-kit-react/tokens';
import {createComponent, ExtractProps, useIsRTL} from '@workday/canvas-kit-react/common';
import {Flex} from '@workday/canvas-kit-react/layout';

import {TextInput} from './TextInput';

export const InputGroupInnerStart = createComponent('div')({
Component(elemProps: ExtractProps<typeof Flex>, ref, Element) {
return (
<Flex
ref={ref}
as={Element}
position="absolute"
alignItems="center"
justifyContent="center"
height="xl"
width="xl"
{...elemProps}
/>
);
},
});

export const InputGroupInnerEnd = createComponent('div')({
Component(elemProps: ExtractProps<typeof Flex>, ref, Element) {
return (
<Flex
ref={ref}
as={Element}
position="absolute"
alignItems="center"
justifyContent="center"
height="xl"
width="xl"
{...elemProps}
/>
);
},
});

export const InputGroupInput = createComponent(TextInput)({
Component(elemProps: ExtractProps<typeof Flex>, ref, Element) {
return <Flex ref={ref} as={Element} width="100%" {...elemProps} />;
},
});

// make sure we always use pixels if the input is a number - this is required for `calc`
const toPx = (input: string | number): string => {
return typeof input === 'number' ? `${input}px` : input;
};

// wrap an array of widths into something the browser can understand, including `calc` for multiple
// values
const wrapInCalc = (values: (string | number)[]): string | number | undefined => {
if (values.length === 0) {
return undefined;
}
if (values.length === 1) {
return values[0];
}
return `calc(${values.map(toPx).join(' + ')})`;
};

/**
* An `InputGroup` is a container around a {@link TextInput} with optional inner start and end
* elements. The inner start and end elements are usually icons or icon buttons visually represented
* inside the input. The `InputGroup` will add padding to the input so the icons/buttons display
* correctly. This component uses `React.Children.map` and `React.cloneElement` from the
* [React.Children](https://react.dev/reference/react/Children) API. This means all children must be
* `InputGroup.*` components. Any other direct children will cause issues. You can add different
* elements/components inside the {@link InputGroupInnerStart InputGroup.InnerStart} and
* {@link InputGroupInnerEnd InputGroup.InnerEnd} subcomponents.
*
* ```tsx
* <InputGroup>
* <InputGroup.InnerStart as={SystemIcon} pointerEvents="none" icon={searchIcon} />
* <InputGroup.Input />
* <InputGroup.InnerEnd>
* <TertiaryButton tabIndex={-1} icon={xIcon} size="small" />
* </InputGroup.InnerEnd>
* </InputGroup>
* ```
*/
export const InputGroup = createComponent('div')({
displayName: 'InputGroup',
Component({children, ...elemProps}: ExtractProps<typeof Flex>, ref, Element) {
const isRTL = useIsRTL();
const offsetsStart: (string | number)[] = [];
const offsetsEnd: (string | number)[] = [];

// Collect the widths of the `InnerStart` and `InnerEnd` components into `offsetStart` and
// `offsetEnd` arrays
React.Children.forEach(children, child => {
if (React.isValidElement<any>(child) && child.type === InputGroupInnerStart) {
const width = child.props.width || space.xl;
offsetsStart.push(width);
}
if (React.isValidElement<any>(child) && child.type === InputGroupInnerEnd) {
const width = child.props.width || space.xl;
offsetsEnd.push(width);
}
});

// keep track of the index offsets to make sure we calculate the correct position offset
let indexStart = 0;
let indexEnd = 0;

// Loop over all the children and set the correct padding and positions
const mappedChildren = React.Children.map(children, child => {
if (React.isValidElement<any>(child)) {
if (child.type === InputGroupInput) {
return React.cloneElement(child, {
paddingInlineStart: wrapInCalc(offsetsStart),
paddingInlineEnd: wrapInCalc(offsetsEnd),
});
}
if (child.type === InputGroupInnerStart) {
const offset = wrapInCalc(offsetsStart.slice(0, indexStart)) || 0;
indexStart++;

return React.cloneElement(child, {
left: isRTL ? undefined : offset,
right: isRTL ? offset : undefined,
});
}
if (child.type === InputGroupInnerEnd) {
const offset = wrapInCalc(offsetsEnd.slice(indexEnd, -1)) || 0;
indexEnd++;

return React.cloneElement(child, {
left: isRTL ? offset : undefined,
right: isRTL ? undefined : offset,
});
}
}
return child;
});

return (
<Flex ref={ref} as={Element} position="relative" {...elemProps}>
{mappedChildren}
</Flex>
);
},
subComponents: {
/**
* A component to show inside and at the start of the input. The input's padding will be
* adjusted to not overlap with this element. Use `width` (number of pixels only) to adjust the
* width offset. The width defaults to 40px which is the correct width for icons or icon
* buttons.
*/
InnerStart: InputGroupInnerStart,
/**
* The input to render. By default, this is a {@link TextInput}. Use the `as` prop to change the
* component to be rendered.
*/
Input: InputGroupInput,
/**
* A component to show inside and at the end of the input. The input's padding will be adjusted
* to not overlap with this element. Use `width` (number of pixels only) to adjust the width
* offset. The width defaults to 40px which is the correct width for icons or icon buttons
* within the input.
*/
InnerEnd: InputGroupInnerEnd,
},
});
28 changes: 28 additions & 0 deletions modules/react/text-input/spec/InputGroup.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from 'react';
import {renderToString} from 'react-dom/server';

import {searchIcon, xSmallIcon} from '@workday/canvas-system-icons-web';
import {InputGroup} from '../lib/InputGroup';
import {TertiaryButton} from '@workday/canvas-kit-react/button';
import {SystemIcon} from '@workday/canvas-kit-react/icon';

describe('InputGroup', () => {
verifyComponent(InputGroup, {});

it('should render on a server without crashing', () => {
const ssrRender = () =>
renderToString(
<InputGroup>
<InputGroup.InnerStart pointerEvents="none">
<SystemIcon icon={searchIcon} size="small" />
</InputGroup.InnerStart>
<InputGroup.Input />
<InputGroup.InnerEnd>
<TertiaryButton role="presentation" icon={xSmallIcon} size="small" tabIndex={-1} />
</InputGroup.InnerEnd>
</InputGroup>
);

expect(ssrRender).not.toThrow();
});
});
18 changes: 18 additions & 0 deletions modules/react/text-input/stories/TextInput.stories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {LabelPosition} from './examples/LabelPosition';
import {Placeholder} from './examples/Placeholder';
import {RefForwarding} from './examples/RefForwarding';
import {Required} from './examples/Required';
import {Icons} from './examples/Icons';

<Meta title="Components/Inputs/Text Input" component={TextInput} />

Expand Down Expand Up @@ -81,6 +82,22 @@ Labels for required fields are suffixed by a red asterisk.

<ExampleCodeBlock code={Required} />

### Icons

`InputGroup` is available to add icons to the `TextInput`. Internally, a container `div` element is
used with relative position styling on the `div` and absolute position styling on the start and end
icons. `InputGroup.InnerStart` and `InputGroup.InnerEnd` are used to position elements at the start
and end of the input. "start" and "end" are used instead of "left" and "right" to match
[CSS Logical Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Logical_Properties)
and will be semantically correct in left-to-right and right-to-left languages.

`InputGroup.InnerStart` and `InputGroup.InnerEnd` subcomponents can handle any child elements, but
are built for icons. The default width is `40px`, which is perfect for icons. If you need to use
something else, be sure to set the `width` property of `InputGroup.InnerStart` or
`InputGroup.InnerEnd` to match the intended width of the element.

<ExampleCodeBlock code={Icons} />

### Error States

Set the `error` prop of the wrapping Form Field to `FormField.ErrorType.Alert` or
Expand All @@ -102,6 +119,7 @@ The `error` prop may be applied directly to the Text Input with a value of
## Component API

<SymbolDoc name="TextInput" fileName="/react/" />
<SymbolDoc name="InputGroup" fileName="/react/" />

## Specifications

Expand Down
Loading

0 comments on commit 2b77c46

Please sign in to comment.