Просмотр исходного кода

Merge pull request #19366 from overleaf/ii-bs5-projects-list-search

[web] BS5 projects list search

GitOrigin-RevId: e2545f43ac3a50e58f7e97a2038e5b768c909e4f
ilkin-overleaf 2 лет назад
Родитель
Сommit
0a23c55c93

+ 1 - 1
services/web/frontend/js/features/clone-project-modal/components/clone-project-modal-content.jsx

@@ -102,7 +102,7 @@ export default function CloneProjectModalContent({
           {clonedProjectTags.length > 0 && (
             <OLFormGroup
               controlId="clone-project-tags-list"
-              className="clone-project-tag mb-3"
+              className="clone-project-tag"
             >
               <OLFormLabel>{t('tags')}: </OLFormLabel>
               <div role="listbox" id="clone-project-tags-list">

+ 0 - 1
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -97,7 +97,6 @@ function ProjectListPageContent() {
           filter={filter}
           selectedTag={selectedTag}
           className="overflow-hidden"
-          formGroupProps={{ className: 'mb-0' }}
         />
       </div>
     </div>

+ 46 - 41
services/web/frontend/js/features/project-list/components/search-form.tsx

@@ -1,36 +1,38 @@
 import { useTranslation } from 'react-i18next'
-import {
-  Form,
-  FormGroup,
-  FormGroupProps,
-  Col,
-  FormControl,
-} from 'react-bootstrap'
+import { FormControl } from 'react-bootstrap'
 import Icon from '../../../shared/components/icon'
 import * as eventTracking from '../../../infrastructure/event-tracking'
 import classnames from 'classnames'
 import { Tag } from '../../../../../app/src/Features/Tags/types'
+import { MergeAndOverride } from '../../../../../types/utils'
 import { Filter } from '../context/project-list-context'
 import { isSmallDevice } from '../../../infrastructure/event-tracking'
+import OLForm from '@/features/ui/components/ol/ol-form'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLCol from '@/features/ui/components/ol/ol-col'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 type SearchFormOwnProps = {
   inputValue: string
   setInputValue: (input: string) => void
   filter: Filter
   selectedTag: Tag | undefined
-  formGroupProps?: FormGroupProps &
-    Omit<React.ComponentProps<'div'>, keyof FormGroupProps>
 }
 
-type SearchFormProps = SearchFormOwnProps &
-  Omit<React.ComponentProps<typeof Form>, keyof SearchFormOwnProps>
+type SearchFormProps = MergeAndOverride<
+  React.ComponentProps<typeof OLForm>,
+  SearchFormOwnProps
+>
 
 function SearchForm({
   inputValue,
   setInputValue,
   filter,
   selectedTag,
-  formGroupProps,
+  className,
   ...props
 }: SearchFormProps) {
   const { t } = useTranslation()
@@ -57,8 +59,6 @@ function SearchForm({
     }
   }
   const placeholder = `${placeholderMessage}…`
-  const { className: formGroupClassName, ...restFormGroupProps } =
-    formGroupProps || {}
 
   const handleChange = (
     e: React.ChangeEvent<
@@ -75,44 +75,49 @@ function SearchForm({
   const handleClear = () => setInputValue('')
 
   return (
-    <Form
-      horizontal
-      className="project-search"
+    <OLForm
+      className={classnames('project-search', className)}
       role="search"
       onSubmit={e => e.preventDefault()}
+      bs3Props={{ horizontal: true }}
       {...props}
     >
-      <FormGroup
-        className={classnames(
-          'has-feedback has-feedback-left',
-          formGroupClassName
-        )}
-        {...restFormGroupProps}
+      <OLFormGroup
+        className={bsVersion({ bs3: 'has-feedback has-feedback-left' })}
       >
-        <Col xs={12}>
-          <FormControl
+        <OLCol>
+          <OLFormControl
             type="text"
             value={inputValue}
             onChange={handleChange}
             placeholder={placeholder}
             aria-label={placeholder}
+            prepend={BootstrapVersionSwitcher({
+              bs3: <Icon type="search" />,
+              bs5: <MaterialIcon type="search" />,
+            })}
+            append={
+              inputValue.length > 0 && (
+                <button
+                  type="button"
+                  className={bsVersion({
+                    bs3: 'project-search-clear-btn btn-link',
+                    bs5: 'project-search-clear-btn',
+                  })}
+                  aria-label={t('clear_search')}
+                  onClick={handleClear}
+                >
+                  <BootstrapVersionSwitcher
+                    bs3={<Icon type="times" />}
+                    bs5={<MaterialIcon type="clear" />}
+                  />
+                </button>
+              )
+            }
           />
-          <Icon type="search" className="form-control-feedback-left" />
-          {inputValue.length ? (
-            <div className="form-control-feedback">
-              <button
-                type="button"
-                className="project-search-clear-btn btn-link"
-                aria-label={t('clear_search')}
-                onClick={handleClear}
-              >
-                <Icon type="times" />
-              </button>
-            </div>
-          ) : null}
-        </Col>
-      </FormGroup>
-    </Form>
+        </OLCol>
+      </OLFormGroup>
+    </OLForm>
   )
 }
 

+ 26 - 0
services/web/frontend/js/features/ui/components/bootstrap-3/form/form-control.tsx

@@ -0,0 +1,26 @@
+import {
+  FormControl as BS3FormControl,
+  FormControlProps as BS3FormControlProps,
+} from 'react-bootstrap'
+
+type FormControlProps = BS3FormControlProps & {
+  prepend?: React.ReactNode
+  append?: React.ReactNode
+}
+
+function FormControl({
+  prepend,
+  append,
+  className,
+  ...props
+}: FormControlProps) {
+  return (
+    <>
+      {prepend && <div className="form-control-feedback-left">{prepend}</div>}
+      <BS3FormControl {...props} />
+      {append && <div className="form-control-feedback">{append}</div>}
+    </>
+  )
+}
+
+export default FormControl

+ 44 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx

@@ -0,0 +1,44 @@
+import { forwardRef } from 'react'
+import { Form, FormControlProps } from 'react-bootstrap-5'
+import classnames from 'classnames'
+
+type OLFormControlProps = FormControlProps & {
+  prepend?: React.ReactNode
+  append?: React.ReactNode
+}
+
+const FormControl = forwardRef<HTMLInputElement, OLFormControlProps>(
+  ({ prepend, append, className, ...props }, ref) => {
+    if (prepend || append) {
+      const wrapperClassNames = classnames('form-control-wrapper', {
+        'form-control-wrapper-sm': props.size === 'sm',
+        'form-control-wrapper-lg': props.size === 'lg',
+        'form-control-wrapper-disabled': props.disabled,
+      })
+
+      const formControlClassNames = classnames(className, {
+        'form-control-offset-start': prepend,
+        'form-control-offset-end': append,
+      })
+
+      return (
+        <div className={wrapperClassNames}>
+          {prepend && (
+            <span className="form-control-start-icon">{prepend}</span>
+          )}
+          <Form.Control
+            {...props}
+            className={formControlClassNames}
+            ref={ref}
+          />
+          {append && <span className="form-control-end-icon">{append}</span>}
+        </div>
+      )
+    }
+
+    return <Form.Control {...props} />
+  }
+)
+FormControl.displayName = 'FormControl'
+
+export default FormControl

+ 14 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/form/form-group.tsx

@@ -0,0 +1,14 @@
+import { forwardRef } from 'react'
+import { FormGroup as BS5FormGroup, FormGroupProps } from 'react-bootstrap-5'
+import classnames from 'classnames'
+
+const FormGroup = forwardRef<typeof BS5FormGroup, FormGroupProps>(
+  ({ className, ...props }, ref) => {
+    const classNames = classnames('form-group', className)
+
+    return <BS5FormGroup className={classNames} {...props} ref={ref} />
+  }
+)
+FormGroup.displayName = 'FormGroup'
+
+export default FormGroup

+ 8 - 5
services/web/frontend/js/features/ui/components/ol/ol-form-control.tsx

@@ -1,11 +1,12 @@
 import { forwardRef } from 'react'
-import { Form } from 'react-bootstrap-5'
-import { FormControl as BS3FormControl } from 'react-bootstrap'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
+import BS3FormControl from '@/features/ui/components/bootstrap-3/form/form-control'
+import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
-type OLFormControlProps = React.ComponentProps<(typeof Form)['Control']> & {
+type OLFormControlProps = React.ComponentProps<typeof FormControl> & {
   bs3Props?: Record<string, unknown>
+  'data-ol-dirty'?: unknown
 }
 
 const OLFormControl = forwardRef<HTMLInputElement, OLFormControlProps>(
@@ -37,6 +38,8 @@ const OLFormControl = forwardRef<HTMLInputElement, OLFormControlProps>(
           ref.current = inputElement
         }
       },
+      prepend: rest.prepend,
+      append: rest.append,
       ...bs3Props,
     }
 
@@ -49,7 +52,7 @@ const OLFormControl = forwardRef<HTMLInputElement, OLFormControlProps>(
     return (
       <BootstrapVersionSwitcher
         bs3={<BS3FormControl {...bs3FormControlProps} />}
-        bs5={<Form.Control ref={ref} {...rest} />}
+        bs5={<FormControl ref={ref} {...rest} />}
       />
     )
   }

+ 6 - 7
services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx

@@ -1,27 +1,26 @@
-import { Form } from 'react-bootstrap-5'
+import { FormGroupProps } from 'react-bootstrap-5'
 import { FormGroup as BS3FormGroup } from 'react-bootstrap'
+import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
-type OLFormGroupProps = React.ComponentProps<(typeof Form)['Group']> & {
+type OLFormGroupProps = FormGroupProps & {
   bs3Props?: Record<string, unknown>
 }
 
 function OLFormGroup(props: OLFormGroupProps) {
-  const { bs3Props, className, ...rest } = props
-
-  const classNames = className ?? 'mb-3'
+  const { bs3Props, ...rest } = props
 
   const bs3FormGroupProps: React.ComponentProps<typeof BS3FormGroup> = {
     children: rest.children,
     controlId: rest.controlId,
-    className,
+    className: rest.className,
     ...bs3Props,
   }
 
   return (
     <BootstrapVersionSwitcher
       bs3={<BS3FormGroup {...bs3FormGroupProps} />}
-      bs5={<Form.Group className={classNames} {...rest} />}
+      bs5={<FormGroup {...rest} />}
     />
   )
 }

+ 2 - 1
services/web/frontend/js/features/ui/components/ol/ol-form.tsx

@@ -11,10 +11,11 @@ function OLForm(props: OLFormProps) {
 
   const bs3FormProps: React.ComponentProps<typeof BS3Form> = {
     componentClass: rest.as,
-    bsClass: rest.className,
     children: rest.children,
     id: rest.id,
     onSubmit: rest.onSubmit as React.FormEventHandler<BS3Form> | undefined,
+    className: rest.className,
+    role: rest.role,
     ...bs3Props,
   }
 

+ 144 - 48
services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx

@@ -1,39 +1,42 @@
 import { Form } from 'react-bootstrap-5'
 import type { Meta, StoryObj } from '@storybook/react'
+import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
 import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
+import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
+import MaterialIcon from '@/shared/components/material-icon'
 
-const meta: Meta<(typeof Form)['Control']> = {
+const meta: Meta<React.ComponentProps<typeof FormControl>> = {
   title: 'Shared / Components / Bootstrap 5 / Form / Input',
-  component: Form.Control,
+  component: FormControl,
   parameters: {
     bootstrap5: true,
   },
 }
 export default meta
 
-type Story = StoryObj<(typeof Form)['Control']>
+type Story = StoryObj<React.ComponentProps<typeof FormControl>>
 
 export const Default: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control defaultValue="Large input" size="lg" {...args} />
+          <FormControl defaultValue="Large input" size="lg" {...args} />
           <FormText>Helper</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control defaultValue="Regular input" {...args} />
+          <FormControl defaultValue="Regular input" {...args} />
           <FormText>Helper</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control defaultValue="Small input" size="sm" {...args} />
+          <FormControl defaultValue="Small input" size="sm" {...args} />
           <FormText>Helper</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -46,37 +49,37 @@ export const Info: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Large input"
             size="lg"
             {...args}
           />
           <FormText isInfo>Info</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Regular input"
             {...args}
           />
           <FormText isInfo>Info</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Small input"
             size="sm"
             {...args}
           />
           <FormText isInfo>Info</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -86,9 +89,9 @@ export const Error: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Large input"
             size="lg"
@@ -96,22 +99,22 @@ export const Error: Story = {
             {...args}
           />
           <FormText isError>Error</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Regular input"
             isInvalid
             {...args}
           />
           <FormText isError>Error</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Small input"
             size="sm"
@@ -119,7 +122,7 @@ export const Error: Story = {
             {...args}
           />
           <FormText isError>Error</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -129,37 +132,37 @@ export const Warning: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Large input"
             size="lg"
             {...args}
           />
           <FormText isWarning>Warning</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Regular input"
             {...args}
           />
           <FormText isWarning>Warning</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Small input"
             size="sm"
             {...args}
           />
           <FormText isWarning>Warning</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -169,37 +172,130 @@ export const Success: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Large input"
             size="lg"
             {...args}
           />
           <FormText isSuccess>Success</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Regular input"
             {...args}
           />
           <FormText isSuccess>Success</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             placeholder="Placeholder"
             defaultValue="Small input"
             size="sm"
             {...args}
           />
           <FormText isSuccess>Success</FormText>
-        </Form.Group>
+        </FormGroup>
+      </>
+    )
+  },
+}
+
+export const WithIcons: Story = {
+  render: args => {
+    const handleClear = () => {
+      alert('Clicked clear button')
+    }
+
+    return (
+      <>
+        <FormGroup controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <FormControl
+            type="text"
+            placeholder="Search"
+            prepend={<MaterialIcon type="search" />}
+            append={
+              <button
+                type="button"
+                className="project-search-clear-btn"
+                onClick={handleClear}
+              >
+                <MaterialIcon type="clear" />
+              </button>
+            }
+            size="lg"
+            {...args}
+          />
+        </FormGroup>
+        <hr />
+        <FormGroup controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <FormControl
+            type="text"
+            placeholder="Search"
+            prepend={<MaterialIcon type="search" />}
+            append={
+              <button
+                type="button"
+                className="project-search-clear-btn"
+                onClick={handleClear}
+              >
+                <MaterialIcon type="clear" />
+              </button>
+            }
+            {...args}
+          />
+        </FormGroup>
+        <hr />
+        <FormGroup controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <FormControl
+            type="text"
+            placeholder="Search"
+            prepend={<MaterialIcon type="search" />}
+            append={
+              <button
+                type="button"
+                className="project-search-clear-btn"
+                onClick={handleClear}
+              >
+                <MaterialIcon type="clear" />
+              </button>
+            }
+            size="sm"
+            {...args}
+          />
+        </FormGroup>
+        <br />
+        <hr />
+        <FormGroup controlId="id-3">
+          <Form.Label>Disabled state</Form.Label>
+          <FormControl
+            type="text"
+            placeholder="Search"
+            prepend={<MaterialIcon type="search" />}
+            append={
+              <button
+                type="button"
+                className="project-search-clear-btn"
+                onClick={handleClear}
+                disabled
+              >
+                <MaterialIcon type="clear" />
+              </button>
+            }
+            disabled
+            {...args}
+          />
+        </FormGroup>
       </>
     )
   },

+ 37 - 36
services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx

@@ -1,8 +1,9 @@
-import { Form } from 'react-bootstrap-5'
+import { Form, FormSelectProps } from 'react-bootstrap-5'
 import type { Meta, StoryObj } from '@storybook/react'
+import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
 import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
 
-const meta: Meta<(typeof Form)['Select']> = {
+const meta: Meta<FormSelectProps> = {
   title: 'Shared / Components / Bootstrap 5 / Form / Select',
   component: Form.Select,
   parameters: {
@@ -11,13 +12,13 @@ const meta: Meta<(typeof Form)['Select']> = {
 }
 export default meta
 
-type Story = StoryObj<(typeof Form)['Select']>
+type Story = StoryObj<FormSelectProps>
 
 export const Default: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
           <Form.Select size="lg" {...args}>
             <option>Large select</option>
@@ -26,9 +27,9 @@ export const Default: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText>Helper</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
           <Form.Select {...args}>
             <option>Regular select</option>
@@ -37,9 +38,9 @@ export const Default: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText>Helper</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
           <Form.Select size="sm" {...args}>
             <option>Small select</option>
@@ -48,7 +49,7 @@ export const Default: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText>Helper</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -61,7 +62,7 @@ export const Info: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
           <Form.Select size="lg" {...args}>
             <option>Large select</option>
@@ -70,9 +71,9 @@ export const Info: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText isInfo>Info</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
           <Form.Select {...args}>
             <option>Regular select</option>
@@ -81,9 +82,9 @@ export const Info: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText isInfo>Info</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
           <Form.Select size="sm" {...args}>
             <option>Small select</option>
@@ -92,7 +93,7 @@ export const Info: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText isInfo>Info</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -102,7 +103,7 @@ export const Error: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
           <Form.Select size="lg" isInvalid {...args}>
             <option>Large select</option>
@@ -111,9 +112,9 @@ export const Error: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText isError>Error</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
           <Form.Select isInvalid {...args}>
             <option>Regular select</option>
@@ -122,9 +123,9 @@ export const Error: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText isError>Error</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
           <Form.Select size="sm" isInvalid {...args}>
             <option>Small select</option>
@@ -133,7 +134,7 @@ export const Error: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText isError>Error</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -143,7 +144,7 @@ export const Warning: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
           <Form.Select size="lg" {...args}>
             <option>Large select</option>
@@ -152,9 +153,9 @@ export const Warning: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText isWarning>Warning</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
           <Form.Select {...args}>
             <option>Regular select</option>
@@ -163,9 +164,9 @@ export const Warning: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText isWarning>Warning</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
           <Form.Select size="sm" {...args}>
             <option>Small select</option>
@@ -174,7 +175,7 @@ export const Warning: Story = {
             <option value="3">Three</option>
           </Form.Select>
           <FormText isWarning>Warning</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -184,38 +185,38 @@ export const Success: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Select size="lg" isSuccess {...args}>
+          <Form.Select size="lg" {...args}>
             <option>Large select</option>
             <option value="1">One</option>
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
           <FormText isSuccess>Success</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Select isSuccess {...args}>
+          <Form.Select {...args}>
             <option>Regular select</option>
             <option value="1">One</option>
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
           <FormText isSuccess>Success</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Select size="sm" isSuccess {...args}>
+          <Form.Select size="sm" {...args}>
             <option>Small select</option>
             <option value="1">One</option>
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
           <FormText isSuccess>Success</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },

+ 50 - 48
services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx

@@ -1,49 +1,51 @@
 import { Form } from 'react-bootstrap-5'
 import type { Meta, StoryObj } from '@storybook/react'
+import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
 import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
+import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
 
-const meta: Meta<(typeof Form)['Control']> = {
+const meta: Meta<React.ComponentProps<typeof FormControl>> = {
   title: 'Shared / Components / Bootstrap 5 / Form / Textarea',
-  component: Form.Control,
+  component: FormControl,
   parameters: {
     bootstrap5: true,
   },
 }
 export default meta
 
-type Story = StoryObj<(typeof Form)['Control']>
+type Story = StoryObj<React.ComponentProps<typeof FormControl>>
 
 export const Default: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             defaultValue="Large input"
             size="lg"
             {...args}
           />
           <FormText>Helper</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control as="textarea" defaultValue="Regular input" {...args} />
+          <FormControl as="textarea" defaultValue="Regular input" {...args} />
           <FormText>Helper</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             defaultValue="Small input"
             size="sm"
             {...args}
           />
           <FormText>Helper</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -56,9 +58,9 @@ export const Info: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Large input"
@@ -66,22 +68,22 @@ export const Info: Story = {
             {...args}
           />
           <FormText isInfo>Info</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Regular input"
             {...args}
           />
           <FormText isInfo>Info</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Small input"
@@ -89,7 +91,7 @@ export const Info: Story = {
             {...args}
           />
           <FormText isInfo>Info</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -99,9 +101,9 @@ export const Error: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Large input"
@@ -110,11 +112,11 @@ export const Error: Story = {
             {...args}
           />
           <FormText isError>Error</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Regular input"
@@ -122,11 +124,11 @@ export const Error: Story = {
             {...args}
           />
           <FormText isError>Error</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Small input"
@@ -135,7 +137,7 @@ export const Error: Story = {
             {...args}
           />
           <FormText isError>Error</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -145,9 +147,9 @@ export const Warning: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Large input"
@@ -155,22 +157,22 @@ export const Warning: Story = {
             {...args}
           />
           <FormText isWarning>Warning</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Regular input"
             {...args}
           />
           <FormText isWarning>Warning</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Small input"
@@ -178,7 +180,7 @@ export const Warning: Story = {
             {...args}
           />
           <FormText isWarning>Warning</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },
@@ -188,9 +190,9 @@ export const Success: Story = {
   render: args => {
     return (
       <>
-        <Form.Group controlId="id-1">
+        <FormGroup controlId="id-1">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Large input"
@@ -198,22 +200,22 @@ export const Success: Story = {
             {...args}
           />
           <FormText isSuccess>Success</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-2">
+        <FormGroup controlId="id-2">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Regular input"
             {...args}
           />
           <FormText isSuccess>Success</FormText>
-        </Form.Group>
+        </FormGroup>
         <hr />
-        <Form.Group controlId="id-3">
+        <FormGroup controlId="id-3">
           <Form.Label>Label</Form.Label>
-          <Form.Control
+          <FormControl
             as="textarea"
             placeholder="Placeholder"
             defaultValue="Small input"
@@ -221,7 +223,7 @@ export const Success: Story = {
             {...args}
           />
           <FormText isSuccess>Success</FormText>
-        </Form.Group>
+        </FormGroup>
       </>
     )
   },

+ 1 - 3
services/web/frontend/stylesheets/app/project-list.less

@@ -386,9 +386,7 @@ ul.folders-menu {
 
 form.project-search {
   .form-group {
-    @media (min-width: @screen-md) {
-      margin-bottom: 0;
-    }
+    margin-bottom: 0;
   }
 }
 

+ 66 - 0
services/web/frontend/stylesheets/bootstrap-5/components/form.scss

@@ -84,3 +84,69 @@
 .form-group {
   margin-bottom: var(--spacing-06);
 }
+
+.form-control-wrapper {
+  position: relative;
+
+  &.form-control-wrapper-disabled {
+    .form-control-start-icon,
+    .form-control-end-icon {
+      & > * {
+        color: var(--content-disabled);
+      }
+    }
+  }
+
+  .form-control-start-icon,
+  .form-control-end-icon {
+    position: absolute;
+    top: 0;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    font-size: 0;
+  }
+
+  .form-control-start-icon {
+    left: 0;
+  }
+  .form-control-end-icon {
+    right: 0;
+  }
+
+  --icon-width: 20px;
+  --form-control-padding-x: var(--spacing-04);
+  --form-control-icon-offset-y: var(--spacing-04);
+
+  &.form-control-wrapper-sm {
+    --form-control-padding-x: var(--spacing-03);
+    --form-control-icon-offset-y: var(--spacing-02);
+  }
+
+  &.form-control-wrapper-lg {
+    --form-control-padding-x: var(--spacing-05);
+    --form-control-icon-offset-y: var(--spacing-05);
+  }
+
+  .form-control-start-icon {
+    padding-left: calc(var(--form-control-padding-x) + var(--bs-border-width));
+  }
+
+  .form-control-end-icon {
+    padding-right: calc(var(--form-control-padding-x) + var(--bs-border-width));
+  }
+
+  .form-control-offset-start {
+    padding-left: calc(
+      var(--form-control-padding-x) + var(--form-control-icon-offset-y) +
+        var(--icon-width)
+    );
+  }
+
+  .form-control-offset-end {
+    padding-right: calc(
+      var(--form-control-padding-x) + var(--form-control-icon-offset-y) +
+        var(--icon-width)
+    );
+  }
+}

+ 10 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss

@@ -464,3 +464,13 @@
 .project-list-load-more-button {
   margin-bottom: var(--spacing-05);
 }
+
+form.project-search {
+  .form-group {
+    margin-bottom: 0;
+  }
+}
+
+.project-search-clear-btn {
+  @include reset-button;
+}