diff options
Diffstat (limited to 'src/components/organisms/forms/search-form')
4 files changed, 133 insertions, 117 deletions
| diff --git a/src/components/organisms/forms/search-form/search-form.module.scss b/src/components/organisms/forms/search-form/search-form.module.scss index 1315fde..db247a2 100644 --- a/src/components/organisms/forms/search-form/search-form.module.scss +++ b/src/components/organisms/forms/search-form/search-form.module.scss @@ -1,57 +1,60 @@  @use "../../../../styles/abstracts/functions" as fun; -@use "../../../../styles/abstracts/mixins" as mix; -.wrapper { -  display: flex; -  align-items: center; -  position: relative; +.input { +  border-right: none; +} -  @include mix.media("screen") { -    @include mix.dimensions("sm") { -      max-width: 35ch; -    } -  } +.icon { +  transform: scale(0.85); +  transition: all 0.3s ease-in-out 0s;  }  .btn { -  align-self: stretch;    background: var(--color-bg-tertiary);    border: fun.convert-px(2) solid var(--color-border); -  border-left: none;    box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow);    transition: all 0.25s linear 0s; -  &__icon { -    transform: scale(0.85); -    transition: all 0.3s ease-in-out 0s; +  &:hover, +  &:focus { +    .icon { +      transform: scale(0.85) rotate(20deg) translateX(#{fun.convert-px(2)}) +        translateY(#{fun.convert-px(3)}); +    }    }    &:focus { -    outline: var(--color-primary-light) solid fun.convert-px(3); +    outline: none; +    border-color: var(--color-primary); +    box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 +      var(--color-primary-dark);    }    &:active { -    outline: none; +    .icon { +      transform: scale(0.7); +    }    } +} + +.wrapper { +  display: flex; -  &:hover &, -  &:focus & { -    &__icon { -      transform: scale(0.85) rotate(20deg) translateY(#{fun.convert-px(3)}); +  &--no-label { +    .btn { +      align-self: stretch;      }    } -  &:active & { -    &__icon { -      transform: scale(0.7); +  &--has-label { +    .btn { +      align-self: flex-end; +      height: fun.convert-px(52);      }    }  }  .field { -  min-width: 0; -  width: 100%; -    &:focus-within ~ .btn {      background: var(--color-bg);      border-color: var(--color-primary); @@ -66,5 +69,10 @@      box-shadow: fun.convert-px(5) fun.convert-px(5) 0 fun.convert-px(1)        var(--color-shadow);      transform: translate(fun.convert-px(-3), fun.convert-px(-3)); + +    &:focus { +      box-shadow: fun.convert-px(5) fun.convert-px(5) 0 fun.convert-px(1) +        var(--color-primary-dark); +    }    }  } diff --git a/src/components/organisms/forms/search-form/search-form.stories.tsx b/src/components/organisms/forms/search-form/search-form.stories.tsx index 971a8ee..d8e4339 100644 --- a/src/components/organisms/forms/search-form/search-form.stories.tsx +++ b/src/components/organisms/forms/search-form/search-form.stories.tsx @@ -5,26 +5,9 @@ import { SearchForm } from './search-form';   * SearchForm - Storybook Meta   */  export default { -  title: 'Organisms/Forms', +  title: 'Organisms/Forms/Search',    component: SearchForm, -  args: { -    isLabelHidden: false, -    searchPage: '#', -  },    argTypes: { -    className: { -      control: { -        type: 'text', -      }, -      description: 'Set additional classnames to the form wrapper.', -      table: { -        category: 'Styles', -      }, -      type: { -        name: 'string', -        required: false, -      }, -    },      isLabelHidden: {        control: {          type: 'boolean', @@ -39,16 +22,6 @@ export default {          required: false,        },      }, -    searchPage: { -      control: { -        type: 'text', -      }, -      description: 'The search results page url.', -      type: { -        name: 'string', -        required: true, -      }, -    },    },  } as ComponentMeta<typeof SearchForm>; @@ -57,9 +30,17 @@ const Template: ComponentStory<typeof SearchForm> = (args) => (  );  /** - * Forms Stories - Search + * SearchForm Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { +  isLabelHidden: false, +}; + +/** + * SearchForm Stories - With hidden label   */ -export const Search = Template.bind({}); -Search.args = { +export const WithHiddenLabel = Template.bind({}); +WithHiddenLabel.args = {    isLabelHidden: true,  }; diff --git a/src/components/organisms/forms/search-form/search-form.test.tsx b/src/components/organisms/forms/search-form/search-form.test.tsx index 908a8eb..56ba0d7 100644 --- a/src/components/organisms/forms/search-form/search-form.test.tsx +++ b/src/components/organisms/forms/search-form/search-form.test.tsx @@ -1,19 +1,39 @@  import { describe, expect, it } from '@jest/globals'; +import { userEvent } from '@testing-library/user-event';  import { render, screen as rtlScreen } from '../../../../../tests/utils';  import { SearchForm } from './search-form';  describe('SearchForm', () => { -  it('renders a search input', () => { -    render(<SearchForm searchPage="#" />); +  it('renders a search input with a submit button', () => { +    render(<SearchForm />); +      expect(        rtlScreen.getByRole('searchbox', { name: 'Search for:' })      ).toBeInTheDocument(); -  }); - -  it('renders a submit button', () => { -    render(<SearchForm searchPage="#" />);      expect(        rtlScreen.getByRole('button', { name: 'Search' })      ).toBeInTheDocument();    }); + +  it('can submit the form', async () => { +    const onSubmit = jest.fn((_search: { query?: string }) => undefined); +    const user = userEvent.setup(); +    const query = 'autem voluptatum eos'; + +    render(<SearchForm onSubmit={onSubmit} />); + +    // eslint-disable-next-line @typescript-eslint/no-magic-numbers +    expect.assertions(3); + +    expect(onSubmit).not.toHaveBeenCalled(); + +    await user.type( +      rtlScreen.getByRole('searchbox', { name: 'Search for:' }), +      query +    ); +    await user.click(rtlScreen.getByRole('button', { name: 'Search' })); + +    expect(onSubmit).toHaveBeenCalledTimes(1); +    expect(onSubmit).toHaveBeenCalledWith({ query }); +  });  }); diff --git a/src/components/organisms/forms/search-form/search-form.tsx b/src/components/organisms/forms/search-form/search-form.tsx index 5c685c0..3f16ad0 100644 --- a/src/components/organisms/forms/search-form/search-form.tsx +++ b/src/components/organisms/forms/search-form/search-form.tsx @@ -1,20 +1,22 @@ -import { useRouter } from 'next/router'; -import { -  type ChangeEvent, -  type FormEvent, -  forwardRef, -  type ForwardRefRenderFunction, -  useId, -  useState, -  useCallback, -} from 'react'; +import { forwardRef, type ForwardRefRenderFunction, useId } from 'react';  import { useIntl } from 'react-intl'; -import { Button, Form, Icon, Input, Label } from '../../../atoms'; +import { type FormSubmitHandler, useForm } from '../../../../utils/hooks'; +import { +  Button, +  Form, +  type FormProps, +  Icon, +  Input, +  Label, +} from '../../../atoms';  import { LabelledField } from '../../../molecules';  import styles from './search-form.module.scss'; -export type SearchFormProps = { -  className?: string; +export type SearchFormData = { query: string }; + +export type SearchFormSubmit = FormSubmitHandler<SearchFormData>; + +export type SearchFormProps = Omit<FormProps, 'children' | 'onSubmit'> & {    /**     * Should the label be visually hidden?     * @@ -22,75 +24,80 @@ export type SearchFormProps = {     */    isLabelHidden?: boolean;    /** -   * The search page url. +   * A callback function to handle search form submit.     */ -  searchPage: string; +  onSubmit?: SearchFormSubmit;  };  const SearchFormWithRef: ForwardRefRenderFunction<    HTMLInputElement,    SearchFormProps -> = ({ className = '', isLabelHidden = false, searchPage }, ref) => { +> = ({ className = '', isLabelHidden = false, onSubmit, ...props }, ref) => {    const intl = useIntl(); -  const fieldLabel = intl.formatMessage({ -    defaultMessage: 'Search for:', -    description: 'SearchForm: field accessible label', -    id: 'X8oujO', -  }); -  const buttonLabel = intl.formatMessage({ -    defaultMessage: 'Search', -    description: 'SearchForm: button accessible name', -    id: 'WMqQrv', +  const { values, submit, submitStatus, update } = useForm<SearchFormData>({ +    initialValues: { query: '' }, +    submitHandler: onSubmit,    }); - -  const router = useRouter(); -  const [value, setValue] = useState<string>(''); - -  const submitHandler = useCallback( -    (e: FormEvent) => { -      e.preventDefault(); -      router.push({ pathname: searchPage, query: { s: value } }); -      setValue(''); -    }, -    [router, searchPage, value] -  ); - -  const updateForm = useCallback((e: ChangeEvent<HTMLInputElement>) => { -    setValue(e.target.value); -  }, []); -    const id = useId(); -  const formClass = `${styles.wrapper} ${className}`; +  const formClass = [ +    styles.wrapper, +    styles[isLabelHidden ? 'wrapper--no-label' : 'wrapper--has-label'], +    className, +  ].join(' '); +  const labels = { +    button: intl.formatMessage({ +      defaultMessage: 'Search', +      description: 'SearchForm: button accessible name', +      id: 'WMqQrv', +    }), +    field: intl.formatMessage({ +      defaultMessage: 'Search for:', +      description: 'SearchForm: field accessible label', +      id: 'X8oujO', +    }), +  };    return ( -    <Form className={formClass} onSubmit={submitHandler}> +    <Form {...props} className={formClass} onSubmit={submit}>        <LabelledField          className={styles.field}          field={            <Input -            className={styles.field} -            id={`search-form-${id}`} -            name="search-form" -            onChange={updateForm} +            className={styles.input} +            id={id} +            // eslint-disable-next-line react/jsx-no-literals +            name="query" +            onChange={update}              ref={ref} +            // eslint-disable-next-line react/jsx-no-literals              type="search" -            value={value} +            value={values.query}            />          }          label={ -          <Label htmlFor={`search-form-${id}`} isHidden={isLabelHidden}> -            {fieldLabel} +          <Label htmlFor={id} isHidden={isLabelHidden}> +            {labels.field}            </Label>          }        />        <Button -        aria-label={buttonLabel} +        aria-label={labels.button}          className={styles.btn} +        isLoading={submitStatus === 'PENDING'} +        // eslint-disable-next-line react/jsx-no-literals          kind="neutral" +        // eslint-disable-next-line react/jsx-no-literals          shape="initial"          type="submit"        > -        <Icon className={styles.btn__icon} shape="magnifying-glass" /> +        <Icon +          aria-hidden +          className={styles.icon} +          // eslint-disable-next-line react/jsx-no-literals +          shape="magnifying-glass" +          // eslint-disable-next-line react/jsx-no-literals +          size="lg" +        />        </Button>      </Form>    ); | 
