Repository: marigold-ui/marigold (ts)

Issue:
Add leading icon and trailing button support to `<Input/>`
## Description

It should be possible to add an icon to an input field to support its usage. This is especially helpful for search field, for example. The leading icon should only be a visual aid and has not functionality besides clicking of it should also focus the input.

For more complex fields, like a date picker input fields need to have the option to display a trailing icon (button). The trailing icon button is interaction and will have user interaction.

This is an image from Material Design to visualize the anatomy of an input with leading and trailing icons:

![image](https://user-images.githubusercontent.com/985701/221175252-0cd86e89-7c35-44b4-b225-98b029760d57.png)

## Proposal

We could either extend the existing `<Input/>` with additional props, or we could create a dedicated component that is composable.

```jsx
<Input
  leading={<SearchIcon />}
  trailing={
    <Button>
      <XIcon />
    </Button>
  }
/>
```

```jsx
<InputContainer>
  <SearchIcon />
  <Input variant="clear" />
  <Button>
    <XIcon />
  </Button>
</InputContainer>
```




Pull request:
chore: refa <Input> and <Input.Field> components and support icons
- old `<Input>` is now `<Input.Field>`
- New `<Input>` is a wrapper for the `<Input.Field>`
- add possibility for icons to the `<Input>`
- styles are now passed to the `<Input>` not the `<Input.Field>`

closes #2818

Patch:
diff --git a/.changeset/healthy-dancers-explode.md b/.changeset/healthy-dancers-explode.md
new file mode 100644
index 000000000..5e5b0642a
--- /dev/null
+++ b/.changeset/healthy-dancers-explode.md
@@ -0,0 +1,6 @@
+---
+"@marigold/docs": patch
+"@marigold/components": minor
+---
+
+chore: refa <Input> and <Input.Field> components and support icons
diff --git a/docs/src/demos/components/FieldGroup/bookings-form.demo.tsx b/docs/src/demos/components/FieldGroup/bookings-form.demo.tsx
index 24829656b..ec6fe0370 100644
--- a/docs/src/demos/components/FieldGroup/bookings-form.demo.tsx
+++ b/docs/src/demos/components/FieldGroup/bookings-form.demo.tsx
@@ -7,7 +7,6 @@ import {
   ThemeProvider,
   Stack,
   FieldBase,
-  Input,
   Select,
   TextArea,
 } from '@marigold/components';
@@ -31,11 +30,10 @@ export const BookingForm = () => (
         </Stack>
         <Stack space="xsmall">
           <TextField label="Straße" />
-          {/* This shouldn't be used, we need to fix it */}
           <FieldBase label="PLZ / Ort">
             <Inline space="xsmall">
-              <Input width="50px" />
-              <Input />
+              <TextField arial-label="PLZ" width="50px" />
+              <TextField arial-label="Ort" width="100px" />
             </Inline>
           </FieldBase>
           <TextField label="Adresszusatz" />
@@ -54,7 +52,7 @@ export const BookingForm = () => (
           {/* This shouldn't be used, we need to fix it */}
           <FieldBase label="nach Kundennummern suchen">
             <Inline space="xsmall">
-              <Input type="search" width="150px" />
+              <TextField arial-label="Search" type="search" width="150px" />
               <Button variant="secondary" size="small">
                 Suchen
               </Button>
diff --git a/docs/src/demos/components/Input/index.ts b/docs/src/demos/components/Input/index.ts
deleted file mode 100644
index c5b8fd8da..000000000
--- a/docs/src/demos/components/Input/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from './input.demo';
-export * from './input-disabled.demo';
-export * from './input-type.demo';
diff --git a/docs/src/demos/components/Input/input-disabled.demo.tsx b/docs/src/demos/components/Input/input-disabled.demo.tsx
deleted file mode 100644
index 1701bf61b..000000000
--- a/docs/src/demos/components/Input/input-disabled.demo.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Input, Label } from '@marigold/components';
-
-export const DisabledInput = () => (
-  <Label htmlFor="input2">
-    Disabled Input
-    <Input id="input2" placeholder="Disabled" disabled />
-  </Label>
-);
diff --git a/docs/src/demos/components/Input/input-type.demo.tsx b/docs/src/demos/components/Input/input-type.demo.tsx
deleted file mode 100644
index 4d093caa5..000000000
--- a/docs/src/demos/components/Input/input-type.demo.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Input, Label, Stack } from '@marigold/components';
-
-export const InputType = () => (
-  <Stack space="medium">
-    <Label htmlFor="input1">
-      Password
-      <Input id="input1" type="password" />
-    </Label>
-    <Label htmlFor="input2">
-      Date
-      <Input id="input2" type="date" />
-    </Label>
-    <Label htmlFor="input3">
-      Submit
-      <Input id="input3" type="submit" />
-    </Label>
-  </Stack>
-);
diff --git a/docs/src/demos/components/Input/input.demo.tsx b/docs/src/demos/components/Input/input.demo.tsx
deleted file mode 100644
index 01b99fbcb..000000000
--- a/docs/src/demos/components/Input/input.demo.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Label, Input } from '@marigold/components';
-
-export const BasicInput = () => (
-  <Label htmlFor="input">
-    Give me your input:
-    <Input id="input" type="text" placeholder="Placeholder" />
-  </Label>
-);
diff --git a/docs/src/demos/components/Label/label-input.demo.tsx b/docs/src/demos/components/Label/label-input.demo.tsx
index 3e35718e1..07843c7df 100644
--- a/docs/src/demos/components/Label/label-input.demo.tsx
+++ b/docs/src/demos/components/Label/label-input.demo.tsx
@@ -3,6 +3,8 @@ import { Label, Input } from '@marigold/components';
 export const LabelInput = () => (
   <Label htmlFor="input">
     Type in your Firstname
-    <Input id="input" type="text" placeholder="Firstname" />
+    <Input>
+      <Input.Field id="input" type="text" placeholder="Firstname" />
+    </Input>
   </Label>
 );
diff --git a/docs/src/demos/components/index.ts b/docs/src/demos/components/index.ts
index 815d83d99..d95bb0668 100644
--- a/docs/src/demos/components/index.ts
+++ b/docs/src/demos/components/index.ts
@@ -26,7 +26,6 @@ export * from './Headline';
 export * from './Icon';
 export * from './Image';
 export * from './Inline';
-export * from './Input';
 export * from './Inset';
 export * from './Label';
 export * from './Link';
diff --git a/docs/src/pages/components/number-field.mdx b/docs/src/pages/components/number-field.mdx
index acb69bedc..a96ff62fc 100644
--- a/docs/src/pages/components/number-field.mdx
+++ b/docs/src/pages/components/number-field.mdx
@@ -36,10 +36,6 @@ To import the component you just have to use this code below.
 import { NumberField } from '@marigold/components';
 ```
 
-### Appearance
-
-<AppearanceTable component={title} />
-
 ### Props
 
 <PropsTable
diff --git a/packages/components/src/Input/Input.stories.tsx b/packages/components/src/Input/Input.stories.tsx
index a9bf9cefc..9607b00f6 100644
--- a/packages/components/src/Input/Input.stories.tsx
+++ b/packages/components/src/Input/Input.stories.tsx
@@ -1,7 +1,9 @@
 import React from 'react';
 import type { Meta, ComponentStory } from '@storybook/react';
-import { Input } from './Input';
 import isChromatic from 'chromatic';
+import { Delete, Search } from '@marigold/icons';
+import { Button } from '../Button';
+import { Input } from './Input';
 
 export default {
   title: 'Components/Input',
@@ -43,10 +45,22 @@ export default {
 } as Meta;
 
 export const Basic: ComponentStory<typeof Input> = args => (
-  <Input placeholder="Placeholder..." {...args} />
+  <Input>
+    <Input.Field placeholder="Placeholder..." {...args} disabled />
+  </Input>
 );
 
 Basic.parameters = {
   chromatic: { viewports: [320, 1200] },
   theme: isChromatic() ? 'b2b' : 'stacked',
 };
+
+export const WithIcons: ComponentStory<typeof Input> = args => (
+  <Input>
+    <Search />
+    <Input.Field placeholder="Placeholder..." />
+    <Button size="small" variant="text">
+      <Delete />
+    </Button>
+  </Input>
+);
diff --git a/packages/components/src/Input/Input.tsx b/packages/components/src/Input/Input.tsx
old mode 100755
new mode 100644
index a6a043ced..9e147e1f3
--- a/packages/components/src/Input/Input.tsx
+++ b/packages/components/src/Input/Input.tsx
@@ -1,6 +1,12 @@
-import React, { forwardRef } from 'react';
+import {
+  Box,
+  ResponsiveStyleValue,
+  ThemeExtension,
+  useComponentStyles,
+} from '@marigold/system';
 import { HtmlProps } from '@marigold/types';
-import { Box, ThemeExtension, useComponentStyles } from '@marigold/system';
+import React, { ReactNode } from 'react';
+import { InputField } from './InputField';
 
 // Theme Extension
 // ---------------
@@ -8,20 +14,42 @@ export interface InputThemeExtension extends ThemeExtension<'Input'> {}
 
 // Props
 // ---------------
-export interface InputOwnProps extends Omit<HtmlProps<'input'>, 'size'> {
-  size?: string;
+export interface InputProps extends Omit<HtmlProps<'div'>, 'size'> {
+  children: ReactNode;
+  space?: ResponsiveStyleValue<string>;
   variant?: string;
+  size?: string;
 }
 
-export interface InputProps
-  extends Omit<React.ComponentPropsWithRef<'input'>, 'size'>,
-    InputOwnProps {}
-
 // Component
 // ---------------
-export const Input = forwardRef<HTMLInputElement, InputOwnProps>(
-  ({ variant, size, type = 'text', ...props }: InputOwnProps, ref) => {
-    const styles = useComponentStyles('Input', { variant, size });
-    return <Box {...props} ref={ref} as="input" type={type} css={styles} />;
-  }
-);
+export const Input = ({
+  space = 'xsmall',
+  children,
+  variant,
+  size,
+  ...props
+}: InputProps) => {
+  const [leading, input, trailing] = React.Children.toArray(children);
+
+  const styles = useComponentStyles('Input', { variant, size });
+
+  return (
+    <Box
+      __baseCSS={{
+        display: 'flex',
+        alignItems: 'center',
+        width: '100%',
+        gap: space,
+      }}
+      css={styles}
+      {...props}
+    >
+      {leading}
+      {input}
+      {trailing}
+    </Box>
+  );
+};
+
+Input.Field = InputField;
diff --git a/packages/components/src/Input/InputField.tsx b/packages/components/src/Input/InputField.tsx
new file mode 100755
index 000000000..3bb409ddb
--- /dev/null
+++ b/packages/components/src/Input/InputField.tsx
@@ -0,0 +1,27 @@
+import React, { forwardRef } from 'react';
+import { HtmlProps } from '@marigold/types';
+import { Box } from '@marigold/system';
+
+// Props
+// ---------------
+export interface InputFieldOwnProps extends Omit<HtmlProps<'input'>, 'size'> {}
+
+export interface InputFieldProps
+  extends Omit<React.ComponentPropsWithRef<'input'>, 'size'>,
+    InputFieldOwnProps {}
+
+// Component
+// ---------------
+export const InputField = forwardRef<HTMLInputElement, InputFieldOwnProps>(
+  ({ type = 'text', ...props }: InputFieldOwnProps, ref) => {
+    return (
+      <Box
+        __baseCSS={{ border: 0, width: '100%', outline: 'none' }}
+        {...props}
+        ref={ref}
+        as="input"
+        type={type}
+      />
+    );
+  }
+);
diff --git a/packages/components/src/Input/index.ts b/packages/components/src/Input/index.ts
index ba9fe7ebc..bb7a7b126 100644
--- a/packages/components/src/Input/index.ts
+++ b/packages/components/src/Input/index.ts
@@ -1,1 +1,2 @@
+export * from './InputField';
 export * from './Input';
diff --git a/packages/components/src/NumberField/NumberField.tsx b/packages/components/src/NumberField/NumberField.tsx
index 45144bf84..10c07b403 100644
--- a/packages/components/src/NumberField/NumberField.tsx
+++ b/packages/components/src/NumberField/NumberField.tsx
@@ -151,17 +151,17 @@ export const NumberField = forwardRef<HTMLInputElement, NumberFieldProps>(
               {...decrementButtonProps}
             />
           )}
-          <Input
-            ref={inputRef}
-            variant={variant}
-            size={size}
-            /**
-             * We use `size` for styles which is a string, not like
-             * the regular HTML attribute, which is a number
-             */
-            {...(inputProps as any)}
-            {...stateProps}
-          />
+          <Input variant={variant} size={size} {...stateProps}>
+            <Input.Field
+              ref={inputRef}
+              /**
+               * We use `size` for styles which is a string, not like
+               * the regular HTML attribute, which is a number
+               */
+              {...(inputProps as any)}
+              {...stateProps}
+            />
+          </Input>
           {showStepper && (
             <StepButton
               direction="up"
diff --git a/packages/components/src/TextField/TextField.tsx b/packages/components/src/TextField/TextField.tsx
index 9c10a3eaa..6b2f6da6a 100644
--- a/packages/components/src/TextField/TextField.tsx
+++ b/packages/components/src/TextField/TextField.tsx
@@ -65,13 +65,13 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
       );
 
     const { hoverProps, isHovered } = useHover({});
-    const { focusProps, isFocusVisible } = useFocusRing({
+    const { focusProps, isFocused } = useFocusRing({
       isTextInput: true,
       autoFocus,
     });
     const stateProps = useStateProps({
       hover: isHovered,
-      focus: isFocusVisible,
+      focus: isFocused,
       disabled,
       readOnly,
       error,
@@ -92,19 +92,21 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
         size={size}
         width={width}
       >
-        <Input
-          ref={inputRef}
-          variant={variant}
-          size={size}
-          /**
-           * We use `size` for styles which is a string, not like
-           * the regular HTML attribute, which is a number
-           */
-          {...(inputProps as any)}
-          {...focusProps}
-          {...hoverProps}
-          {...stateProps}
-        />
+        <Input {...stateProps}>
+          <Input.Field
+            ref={inputRef}
+            variant={variant}
+            size={size}
+            /**
+             * We use `size` for styles which is a string, not like
+             * the regular HTML attribute, which is a number
+             */
+            {...(inputProps as any)}
+            {...focusProps}
+            {...hoverProps}
+            {...stateProps}
+          />
+        </Input>
       </FieldBase>
     );
   }


Test patch:
diff --git a/packages/components/src/Input/Input.test.tsx b/packages/components/src/Input/Input.test.tsx
index c9f867541..d2c15a777 100644
--- a/packages/components/src/Input/Input.test.tsx
+++ b/packages/components/src/Input/Input.test.tsx
@@ -2,6 +2,7 @@ import React from 'react';
 import { render, screen } from '@testing-library/react';
 import { ThemeProvider } from '@marigold/system';
 import { Input } from './Input';
+import { Delete, Search } from '@marigold/icons';
 
 const theme = {
   fonts: {
@@ -25,10 +26,12 @@ const theme = {
 test('supports default variant and themeSection', () => {
   render(
     <ThemeProvider theme={theme}>
-      <Input title="input" />
+      <Input data-testid="input">
+        <Input.Field title="input" />
+      </Input>
     </ThemeProvider>
   );
-  const input = screen.getByTitle('input');
+  const input = screen.getByTestId('input');
 
   expect(input).toHaveStyle(`font-family: Inter`);
 });
@@ -36,10 +39,12 @@ test('supports default variant and themeSection', () => {
 test('accepts other variant than default', () => {
   render(
     <ThemeProvider theme={theme}>
-      <Input title="input" variant="robo" />
+      <Input data-testid="input" variant="robo">
+        <Input.Field title="input" />
+      </Input>
     </ThemeProvider>
   );
-  const input = screen.getByTitle('input');
+  const input = screen.getByTestId('input');
 
   expect(input).toHaveStyle(`font-family: Roboto`);
 });
@@ -47,7 +52,9 @@ test('accepts other variant than default', () => {
 test('renders correct HTML element', () => {
   render(
     <ThemeProvider theme={theme}>
-      <Input title="input" />
+      <Input data-testid="input">
+        <Input.Field title="input" />
+      </Input>
     </ThemeProvider>
   );
   const input = screen.getByTitle('input');
@@ -58,10 +65,54 @@ test('renders correct HTML element', () => {
 test('supports custom prop', () => {
   render(
     <ThemeProvider theme={theme}>
-      <Input title="input" placeholder="placeholder" />
+      <Input data-testid="input">
+        <Input.Field title="input" placeholder="placeholder" />
+      </Input>
     </ThemeProvider>
   );
   const input = screen.getByTitle('input');
 
   expect(input).toHaveAttribute('placeholder');
 });
+
+test('renders correct HTML Div element', () => {
+  render(
+    <ThemeProvider theme={theme}>
+      <Input data-testid="input">
+        <Input.Field title="input" />
+      </Input>
+    </ThemeProvider>
+  );
+  const input = screen.getByTestId('input');
+
+  expect(input instanceof HTMLDivElement).toBeTruthy();
+});
+
+test('renders space prop', () => {
+  render(
+    <ThemeProvider theme={theme}>
+      <Input data-testid="input" space="4px">
+        <Input.Field title="input" />
+      </Input>
+    </ThemeProvider>
+  );
+  const input = screen.getByTestId('input');
+
+  expect(input).toHaveStyle('gap: 4px');
+});
+
+test('renders icons within', () => {
+  render(
+    <ThemeProvider theme={theme}>
+      <Input data-testid="input">
+        <Search />
+        <Input.Field title="input" />
+        <Delete />
+      </Input>
+    </ThemeProvider>
+  );
+  const input = screen.getByTestId('input');
+
+  // eslint-disable-next-line testing-library/no-node-access
+  expect(input.children).toBeTruthy();
+});
diff --git a/packages/components/src/NumberField/NumberField.test.tsx b/packages/components/src/NumberField/NumberField.test.tsx
index d8490b2b8..99c2c016e 100644
--- a/packages/components/src/NumberField/NumberField.test.tsx
+++ b/packages/components/src/NumberField/NumberField.test.tsx
@@ -134,7 +134,10 @@ test('input can be styled via "Input" styles', () => {
     </ThemeProvider>
   );
   const numberField = screen.getByTestId('number-field');
-  expect(numberField).toHaveStyle(`border-color: ${theme.colors.blue}`);
+  // eslint-disable-next-line testing-library/no-node-access
+  expect(numberField.parentElement).toHaveStyle(
+    `border-color: ${theme.colors.blue}`
+  );
 });
 
 test('group and stepper can styled via "NumberField" styles', () => {
@@ -152,32 +155,6 @@ test('group and stepper can styled via "NumberField" styles', () => {
   expect(steppers[1]).toHaveStyle(`color: ${theme.colors.black}`);
 });
 
-test('passes down variant and size', () => {
-  render(
-    <ThemeProvider theme={theme}>
-      <NumberField
-        data-testid="number-field"
-        label="Label"
-        description="Description"
-        variant="lime"
-        size="small"
-      />
-    </ThemeProvider>
-  );
-
-  const numberField = screen.getByTestId('number-field');
-  expect(numberField).toHaveStyle(`color: ${theme.colors.lime}`);
-  expect(numberField).toHaveStyle(`font-size: ${theme.fontSizes['small-1']}px`);
-
-  const label = screen.getByText('Label');
-  expect(label).toHaveStyle(`color: ${theme.colors.lime}`);
-  expect(label).toHaveStyle(`font-size: ${theme.fontSizes['small-1']}px`);
-
-  const description = screen.getByText('Description');
-  expect(description).toHaveStyle(`color: ${theme.colors.lime}`);
-  expect(description).toHaveStyle(`font-size: ${theme.fontSizes['small-1']}px`);
-});
-
 test('allows to set width via prop', () => {
   render(
     <ThemeProvider theme={theme}>
diff --git a/packages/components/src/TextField/TextField.test.tsx b/packages/components/src/TextField/TextField.test.tsx
index f14b93c39..7b6bf76c4 100644
--- a/packages/components/src/TextField/TextField.test.tsx
+++ b/packages/components/src/TextField/TextField.test.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable testing-library/no-node-access */
 import React from 'react';
 import { render, screen, waitFor } from '@testing-library/react';
 import { ThemeProvider } from '@marigold/system';
@@ -50,16 +51,6 @@ const theme = {
       base: {
         borderColor: 'blue',
       },
-      variant: {
-        lime: {
-          color: 'lime',
-        },
-      },
-      size: {
-        small: {
-          fontSize: 'small-1',
-        },
-      },
     },
   },
 };
@@ -88,33 +79,9 @@ test('input can be styled via "Input" styles', () => {
     </ThemeProvider>
   );
   const textField = screen.getByTestId('text-field');
-  expect(textField).toHaveStyle(`border-color: ${theme.colors.blue}`);
-});
-
-test('passes down variant and size', () => {
-  render(
-    <ThemeProvider theme={theme}>
-      <TextField
-        data-testid="text-field"
-        label="Label"
-        description="Description"
-        variant="lime"
-        size="small"
-      />
-    </ThemeProvider>
+  expect(textField.parentElement).toHaveStyle(
+    `border-color: ${theme.colors.blue}`
   );
-
-  const textField = screen.getByTestId('text-field');
-  expect(textField).toHaveStyle(`color: ${theme.colors.lime}`);
-  expect(textField).toHaveStyle(`font-size: ${theme.fontSizes['small-1']}px`);
-
-  const label = screen.getByText('Label');
-  expect(label).toHaveStyle(`color: ${theme.colors.lime}`);
-  expect(label).toHaveStyle(`font-size: ${theme.fontSizes['small-1']}px`);
-
-  const description = screen.getByText('Description');
-  expect(description).toHaveStyle(`color: ${theme.colors.lime}`);
-  expect(description).toHaveStyle(`font-size: ${theme.fontSizes['small-1']}px`);
 });
 
 test('takes full width by default', () => {