ソースを参照

Merge pull request #18103 from overleaf/ii-bs5-forms

[web] Bootstrap 5 form elements

GitOrigin-RevId: 7d031bed07007d0aa00a43f06d25bfb7384dee87
ilkin-overleaf 2 年 前
コミット
482bd7fb9c
28 ファイル変更1478 行追加154 行削除
  1. 19 21
      services/web/frontend/js/features/settings/components/account-info-section.tsx
  2. 2 3
      services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx
  3. 11 9
      services/web/frontend/js/features/settings/components/emails/add-email/input.tsx
  4. 10 11
      services/web/frontend/js/features/settings/components/emails/add-email/institution-fields.tsx
  5. 2 3
      services/web/frontend/js/features/settings/components/emails/downshift-input.tsx
  6. 5 4
      services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx
  7. 28 29
      services/web/frontend/js/features/settings/components/leave/modal-form.tsx
  8. 23 18
      services/web/frontend/js/features/settings/components/password-section.tsx
  9. 74 0
      services/web/frontend/js/features/ui/components/bootstrap-5/form/form-text.tsx
  10. 32 0
      services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-checkbox-wrapper.tsx
  11. 71 0
      services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-control-wrapper.tsx
  12. 29 0
      services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-group-wrapper.tsx
  13. 27 0
      services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-label-wrapper.tsx
  14. 38 0
      services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-text-wrapper.tsx
  15. 65 0
      services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx
  16. 206 0
      services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx
  17. 54 0
      services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx
  18. 222 0
      services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx
  19. 228 0
      services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx
  20. 111 0
      services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss
  21. 4 0
      services/web/frontend/stylesheets/bootstrap-5/base/base.scss
  22. 1 0
      services/web/frontend/stylesheets/bootstrap-5/base/bootstrap.scss
  23. 2 0
      services/web/frontend/stylesheets/bootstrap-5/components/all.scss
  24. 82 0
      services/web/frontend/stylesheets/bootstrap-5/components/form.scss
  25. 20 0
      services/web/frontend/stylesheets/bootstrap-5/components/input-suggestions.scss
  26. 1 0
      services/web/frontend/stylesheets/bootstrap-5/main-style.scss
  27. 2 9
      services/web/frontend/stylesheets/components/input-suggestions.less
  28. 109 47
      services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx

+ 19 - 21
services/web/frontend/js/features/settings/components/account-info-section.tsx

@@ -1,5 +1,4 @@
 import { useState } from 'react'
-import { ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import {
   getUserFacingMessage,
@@ -11,6 +10,10 @@ import useAsync from '../../../shared/hooks/use-async'
 import { useUserContext } from '../../../shared/context/user-context'
 import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 import NotificationWrapper from '@/features/ui/components/bootstrap-5/wrappers/notification-wrapper'
+import FormGroupWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-group-wrapper'
+import FormLabelWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-label-wrapper'
+import FormControlWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-control-wrapper'
+import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
 
 function AccountInfoSection() {
   const { t } = useTranslation()
@@ -104,20 +107,20 @@ function AccountInfoSection() {
           required={false}
         />
         {isSuccess ? (
-          <FormGroup>
+          <FormGroupWrapper>
             <NotificationWrapper
               type="success"
               content={t('thanks_settings_updated')}
             />
-          </FormGroup>
+          </FormGroupWrapper>
         ) : null}
         {isError ? (
-          <FormGroup>
+          <FormGroupWrapper>
             <NotificationWrapper
               type="error"
               content={getUserFacingMessage(error) ?? ''}
             />
-          </FormGroup>
+          </FormGroupWrapper>
         ) : null}
         {canUpdateEmail || canUpdateNames ? (
           <ButtonWrapper
@@ -159,14 +162,12 @@ function ReadOrWriteFormGroup({
 }: ReadOrWriteFormGroupProps) {
   const [validationMessage, setValidationMessage] = useState('')
 
-  const handleInvalid = (
-    event: React.InvalidEvent<HTMLInputElement & FormControl>
-  ) => {
+  const handleInvalid = (event: React.InvalidEvent<HTMLInputElement>) => {
     event.preventDefault()
   }
 
   const handleChangeAndValidity = (
-    event: React.ChangeEvent<HTMLInputElement & FormControl>
+    event: React.ChangeEvent<HTMLInputElement>
   ) => {
     handleChange(event)
     setValidationMessage(event.target.validationMessage)
@@ -174,18 +175,17 @@ function ReadOrWriteFormGroup({
 
   if (!canEdit) {
     return (
-      <FormGroup>
-        <ControlLabel htmlFor={id}>{label}</ControlLabel>
-        <FormControl id={id} type="text" readOnly value={value} />
-      </FormGroup>
+      <FormGroupWrapper controlId={id}>
+        <FormLabelWrapper>{label}</FormLabelWrapper>
+        <FormControlWrapper type="text" readOnly value={value} />
+      </FormGroupWrapper>
     )
   }
 
   return (
-    <FormGroup>
-      <ControlLabel htmlFor={id}>{label}</ControlLabel>
-      <FormControl
-        id={id}
+    <FormGroupWrapper controlId={id}>
+      <FormLabelWrapper>{label}</FormLabelWrapper>
+      <FormControlWrapper
         type={type}
         required={required}
         value={value}
@@ -193,10 +193,8 @@ function ReadOrWriteFormGroup({
         onChange={handleChangeAndValidity}
         onInvalid={handleInvalid}
       />
-      {validationMessage ? (
-        <span className="small text-danger">{validationMessage}</span>
-      ) : null}
-    </FormGroup>
+      {validationMessage && <FormText isError>{validationMessage}</FormText>}
+    </FormGroupWrapper>
   )
 }
 

+ 2 - 3
services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx

@@ -4,6 +4,7 @@ import { useCombobox } from 'downshift'
 import classnames from 'classnames'
 import countries, { CountryCode } from '../../../data/countries-list'
 import { bsVersion } from '@/features/utils/bootstrap-5'
+import FormControlWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-control-wrapper'
 
 type CountryInputProps = {
   setValue: React.Dispatch<React.SetStateAction<CountryCode | null>>
@@ -62,7 +63,7 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
         >
           {t('country')}
         </label>
-        <input
+        <FormControlWrapper
           {...getInputProps({
             onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
               setInputValue(event.target.value)
@@ -74,8 +75,6 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
             },
             ref: inputRef,
           })}
-          className="form-control"
-          type="text"
           placeholder={t('country')}
         />
         <i className="caret" />

+ 11 - 9
services/web/frontend/js/features/settings/components/emails/add-email/input.tsx

@@ -11,6 +11,7 @@ import { getJSON } from '../../../../../infrastructure/fetch-json'
 import useAbortController from '../../../../../shared/hooks/use-abort-controller'
 import domainBlocklist from '../../../domain-blocklist'
 import { debugConsole } from '@/utils/debugging'
+import FormControlWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-control-wrapper'
 
 const LOCAL_AND_DOMAIN_REGEX = /([^@]+)@(.+)/
 
@@ -48,7 +49,7 @@ type InputProps = {
 function Input({ onChange, handleAddNewEmail }: InputProps) {
   const { signal } = useAbortController()
 
-  const inputRef = useRef<HTMLInputElement | null>(null)
+  const inputRef = useRef<HTMLInputElement>(null)
   const [suggestion, setSuggestion] = useState<string | null>(null)
   const [inputValue, setInputValue] = useState<string | null>(null)
   const [matchedDomain, setMatchedDomain] = useState<DomainInfo | null>(null)
@@ -161,15 +162,16 @@ function Input({ onChange, handleAddNewEmail }: InputProps) {
 
   return (
     <div className="input-suggestions">
-      <div className="form-control input-suggestions-shadow">
-        <div className="input-suggestions-shadow-suggested">
-          {suggestion || ''}
-        </div>
-      </div>
-
-      <input
+      <FormControlWrapper
+        data-testid="affiliations-email-shadow"
+        readOnly
+        className="input-suggestions-shadow"
+        value={suggestion || ''}
+      />
+      <FormControlWrapper
         id="affiliations-email"
-        className="form-control input-suggestions-main"
+        data-testid="affiliations-email"
+        className="input-suggestions-main"
         type="email"
         onChange={handleEmailChange}
         onKeyDown={handleKeyDownEvent}

+ 10 - 11
services/web/frontend/js/features/settings/components/emails/add-email/institution-fields.tsx

@@ -11,6 +11,7 @@ import { DomainInfo } from './input'
 import { getJSON } from '../../../../../infrastructure/fetch-json'
 import useAsync from '../../../../../shared/hooks/use-async'
 import UniversityName from './university-name'
+import FormGroupWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-group-wrapper'
 
 type InstitutionFieldsProps = {
   countryCode: CountryCode | null
@@ -151,17 +152,15 @@ function InstitutionFields({
       ) : (
         // Display the country and university fields
         <>
-          <div className="form-group mb-2">
+          <FormGroupWrapper className="mb-2">
             <CountryInput
               id="new-email-country-input"
               setValue={setCountryCode}
               ref={countryRef}
             />
-          </div>
-          <div
-            className={`form-group ${
-              isRoleAndDepartmentVisible ? 'mb-2' : 'mb-0'
-            }`}
+          </FormGroupWrapper>
+          <FormGroupWrapper
+            className={isRoleAndDepartmentVisible ? 'mb-2' : 'mb-0'}
           >
             <DownshiftInput
               items={getUniversityItems()}
@@ -171,12 +170,12 @@ function InstitutionFields({
               setValue={setUniversityName}
               disabled={!countryCode}
             />
-          </div>
+          </FormGroupWrapper>
         </>
       )}
       {isRoleAndDepartmentVisible && (
         <>
-          <div className="form-group mb-2">
+          <FormGroupWrapper className="mb-2">
             <DownshiftInput
               items={[...defaultRoles]}
               inputValue={role}
@@ -184,8 +183,8 @@ function InstitutionFields({
               label={t('role')}
               setValue={setRole}
             />
-          </div>
-          <div className="form-group mb-0">
+          </FormGroupWrapper>
+          <FormGroupWrapper className="mb-0">
             <DownshiftInput
               items={departments}
               inputValue={department}
@@ -193,7 +192,7 @@ function InstitutionFields({
               label={t('department')}
               setValue={setDepartment}
             />
-          </div>
+          </FormGroupWrapper>
         </>
       )}
     </>

+ 2 - 3
services/web/frontend/js/features/settings/components/emails/downshift-input.tsx

@@ -3,6 +3,7 @@ import { useCombobox } from 'downshift'
 import classnames from 'classnames'
 import { escapeRegExp } from 'lodash'
 import { bsVersion } from '@/features/utils/bootstrap-5'
+import FormControlWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-control-wrapper'
 
 type DownshiftInputProps = {
   highlightMatches?: boolean
@@ -97,7 +98,7 @@ function Downshift({
         >
           {label}
         </label>
-        <input
+        <FormControlWrapper
           {...getInputProps({
             onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
               setValue(event.target.value)
@@ -109,8 +110,6 @@ function Downshift({
             },
             ref: inputRef,
           })}
-          className="form-control"
-          type="text"
           placeholder={placeholder}
           disabled={disabled}
         />

+ 5 - 4
services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx

@@ -10,6 +10,7 @@ import defaultRoles from '../../data/roles'
 import defaultDepartments from '../../data/departments'
 import { University } from '../../../../../../types/university'
 import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
+import FormGroupWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-group-wrapper'
 
 type InstitutionAndRoleProps = {
   userEmailData: UserEmailData
@@ -120,7 +121,7 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
       ) : (
         <div className="affiliation-change-container small">
           <form onSubmit={handleSubmit}>
-            <div className="form-group mb-2">
+            <FormGroupWrapper className="mb-2">
               <DownshiftInput
                 items={[...defaultRoles]}
                 inputValue={role}
@@ -129,8 +130,8 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
                 setValue={setRole}
                 ref={roleRef}
               />
-            </div>
-            <div className="form-group mb-2">
+            </FormGroupWrapper>
+            <FormGroupWrapper className="mb-2">
               <DownshiftInput
                 items={departments}
                 inputValue={department}
@@ -138,7 +139,7 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
                 label={t('department')}
                 setValue={setDepartment}
               />
-            </div>
+            </FormGroupWrapper>
             <ButtonWrapper
               size="small"
               variant="primary"

+ 28 - 29
services/web/frontend/js/features/settings/components/leave/modal-form.tsx

@@ -1,10 +1,13 @@
 import { useState, useEffect, Dispatch, SetStateAction } from 'react'
-import { Checkbox, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
 import { useTranslation, Trans } from 'react-i18next'
 import { postJSON, FetchError } from '../../../../infrastructure/fetch-json'
 import getMeta from '../../../../utils/meta'
 import LeaveModalFormError from './modal-form-error'
 import { useLocation } from '../../../../shared/hooks/use-location'
+import FormGroupWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-group-wrapper'
+import FormLabelWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-label-wrapper'
+import FormControlWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-control-wrapper'
+import FormCheckboxWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-checkbox-wrapper'
 
 export type LeaveModalFormProps = {
   setInFlight: Dispatch<SetStateAction<boolean>>
@@ -26,15 +29,11 @@ function LeaveModalForm({
   const [confirmation, setConfirmation] = useState(false)
   const [error, setError] = useState<FetchError | null>(null)
 
-  const handleEmailChange = (
-    event: React.ChangeEvent<HTMLFormElement & FormControl>
-  ) => {
+  const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
     setEmail(event.target.value)
   }
 
-  const handlePasswordChange = (
-    event: React.ChangeEvent<HTMLFormElement & FormControl>
-  ) => {
+  const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
     setPassword(event.target.value)
   }
 
@@ -74,43 +73,43 @@ function LeaveModalForm({
 
   return (
     <form id="leave-form" onSubmit={handleSubmit}>
-      <FormGroup>
-        <ControlLabel htmlFor="email-input">{t('email')}</ControlLabel>
-        <FormControl
-          id="email-input"
+      <FormGroupWrapper controlId="email-input">
+        <FormLabelWrapper>{t('email')}</FormLabelWrapper>
+        <FormControlWrapper
           type="text"
           placeholder={t('email')}
           required
           value={email}
           onChange={handleEmailChange}
         />
-      </FormGroup>
-      <FormGroup>
-        <ControlLabel htmlFor="password-input">{t('password')}</ControlLabel>
-        <FormControl
-          id="password-input"
+      </FormGroupWrapper>
+      <FormGroupWrapper controlId="password-input">
+        <FormLabelWrapper>{t('password')}</FormLabelWrapper>
+        <FormControlWrapper
           type="password"
           placeholder={t('password')}
           required
           value={password}
           onChange={handlePasswordChange}
         />
-      </FormGroup>
-      <Checkbox
+      </FormGroupWrapper>
+      <FormCheckboxWrapper
+        id="confirm-account-deletion"
         required
         checked={confirmation}
         onChange={handleConfirmationChange}
-      >
-        <Trans
-          i18nKey="delete_account_confirmation_label"
-          components={[<i />]} // eslint-disable-line react/jsx-key
-          values={{
-            userDefaultEmail,
-          }}
-          shouldUnescape
-          tOptions={{ interpolation: { escapeValue: true } }}
-        />
-      </Checkbox>
+        label={
+          <Trans
+            i18nKey="delete_account_confirmation_label"
+            components={[<i />]} // eslint-disable-line react/jsx-key
+            values={{
+              userDefaultEmail,
+            }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
+          />
+        }
+      />
       {error ? <LeaveModalFormError error={error} /> : null}
     </form>
   )

+ 23 - 18
services/web/frontend/js/features/settings/components/password-section.tsx

@@ -1,5 +1,4 @@
 import { useEffect, useState } from 'react'
-import { ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
 import { Trans, useTranslation } from 'react-i18next'
 import {
   getUserFacingMessage,
@@ -12,6 +11,10 @@ import { PasswordStrengthOptions } from '../../../../../types/password-strength-
 import useAsync from '../../../shared/hooks/use-async'
 import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 import NotificationWrapper from '@/features/ui/components/bootstrap-5/wrappers/notification-wrapper'
+import FormGroupWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-group-wrapper'
+import FormLabelWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-label-wrapper'
+import FormControlWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-control-wrapper'
+import FormTextWrapper from '@/features/ui/components/bootstrap-5/wrappers/form-text-wrapper'
 
 type PasswordUpdateResult = {
   message?: {
@@ -156,12 +159,12 @@ function PasswordForm() {
         autoComplete="new-password"
       />
       {isSuccess && data?.message?.text ? (
-        <FormGroup>
+        <FormGroupWrapper>
           <NotificationWrapper type="success" content={data.message.text} />
-        </FormGroup>
+        </FormGroupWrapper>
       ) : null}
       {isError ? (
-        <FormGroup>
+        <FormGroupWrapper>
           <NotificationWrapper
             type="error"
             content={
@@ -195,7 +198,7 @@ function PasswordForm() {
               )
             }
           />
-        </FormGroup>
+        </FormGroupWrapper>
       ) : null}
       <ButtonWrapper
         form="password-change-form"
@@ -235,25 +238,26 @@ function PasswordFormGroup({
   const [validationMessage, setValidationMessage] = useState('')
   const [hadInteraction, setHadInteraction] = useState(false)
 
-  const handleInvalid = (
-    event: React.InvalidEvent<HTMLInputElement & FormControl>
-  ) => {
+  const handleInvalid = (event: React.InvalidEvent<HTMLInputElement>) => {
     event.preventDefault()
   }
 
   const handleChangeAndValidity = (
-    event: React.ChangeEvent<HTMLInputElement & FormControl>
+    event: React.ChangeEvent<HTMLInputElement>
   ) => {
     handleChange(event)
     setHadInteraction(true)
     setValidationMessage(event.target.validationMessage)
   }
 
+  const isInvalid = Boolean(
+    hadInteraction && (parentValidationMessage || validationMessage)
+  )
+
   return (
-    <FormGroup>
-      <ControlLabel htmlFor={id}>{label}</ControlLabel>
-      <FormControl
-        id={id}
+    <FormGroupWrapper controlId={id}>
+      <FormLabelWrapper>{label}</FormLabelWrapper>
+      <FormControlWrapper
         type="password"
         placeholder="*********"
         autoComplete={autoComplete}
@@ -263,13 +267,14 @@ function PasswordFormGroup({
         onInvalid={handleInvalid}
         required={hadInteraction}
         minLength={minLength}
+        isInvalid={isInvalid}
       />
-      {hadInteraction && (parentValidationMessage || validationMessage) ? (
-        <span className="small text-danger">
+      {isInvalid && (
+        <FormTextWrapper isError>
           {parentValidationMessage || validationMessage}
-        </span>
-      ) : null}
-    </FormGroup>
+        </FormTextWrapper>
+      )}
+    </FormGroupWrapper>
   )
 }
 

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

@@ -0,0 +1,74 @@
+import { Form } from 'react-bootstrap-5'
+import { MergeAndOverride } from '../../../../../../../types/utils'
+import MaterialIcon from '@/shared/components/material-icon'
+import classnames from 'classnames'
+
+type FormTextProps = MergeAndOverride<
+  React.ComponentProps<(typeof Form)['Text']>,
+  | {
+      isInfo?: boolean
+      isError?: never
+      isWarning?: never
+      isSuccess?: never
+    }
+  | {
+      isInfo?: never
+      isError?: boolean
+      isWarning?: never
+      isSuccess?: never
+    }
+  | {
+      isInfo?: never
+      isError?: never
+      isWarning?: boolean
+      isSuccess?: never
+    }
+  | {
+      isInfo?: never
+      isError?: never
+      isWarning?: never
+      isSuccess?: boolean
+    }
+>
+
+export const getFormTextColor = ({
+  isError,
+  isSuccess,
+  isWarning,
+}: {
+  isError?: boolean
+  isSuccess?: boolean
+  isWarning?: boolean
+}) => ({
+  'text-danger': isError,
+  'text-success': isSuccess,
+  'text-warning': isWarning,
+})
+
+function FormText({
+  isInfo,
+  isError,
+  isWarning,
+  isSuccess,
+  children,
+  className,
+  ...rest
+}: FormTextProps) {
+  return (
+    <Form.Text
+      className={classnames(
+        className,
+        getFormTextColor({ isError, isSuccess, isWarning })
+      )}
+      {...rest}
+    >
+      {isInfo && <MaterialIcon type="info" className="text-info" />}
+      {isError && <MaterialIcon type="error" />}
+      {isWarning && <MaterialIcon type="warning" />}
+      {isSuccess && <MaterialIcon type="check_circle" />}
+      {children}
+    </Form.Text>
+  )
+}
+
+export default FormText

+ 32 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-checkbox-wrapper.tsx

@@ -0,0 +1,32 @@
+import { Form } from 'react-bootstrap-5'
+import { Checkbox as BS3Checkbox } from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+type FormCheckboxWrapperProps = React.ComponentProps<(typeof Form)['Check']> & {
+  bs3Props?: Record<string, unknown>
+}
+
+function FormCheckboxWrapper(props: FormCheckboxWrapperProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3FormLabelProps: React.ComponentProps<typeof BS3Checkbox> = {
+    children: rest.label,
+    checked: rest.checked,
+    required: rest.required,
+    readOnly: rest.readOnly,
+    disabled: rest.disabled,
+    inline: rest.inline,
+    title: rest.title,
+    onChange: rest.onChange as (e: React.ChangeEvent<unknown>) => void,
+    ...bs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3Checkbox {...bs3FormLabelProps} />}
+      bs5={<Form.Check {...rest} />}
+    />
+  )
+}
+
+export default FormCheckboxWrapper

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

@@ -0,0 +1,71 @@
+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'
+
+type FormControlWrapperProps = React.ComponentProps<
+  (typeof Form)['Control']
+> & {
+  bs3Props?: Record<string, unknown>
+}
+
+const FormControlWrapper = forwardRef<
+  HTMLInputElement,
+  FormControlWrapperProps
+>((props, ref) => {
+  const { bs3Props, ...rest } = props
+
+  let bs3FormControlProps: React.ComponentProps<typeof BS3FormControl> = {
+    id: rest.id,
+    className: rest.className,
+    style: rest.style,
+    type: rest.type,
+    value: rest.value,
+    required: rest.required,
+    disabled: rest.disabled,
+    placeholder: rest.placeholder,
+    readOnly: rest.readOnly,
+    autoComplete: rest.autoComplete,
+    minLength: rest.minLength,
+    maxLength: rest.maxLength,
+    onChange: rest.onChange as (e: React.ChangeEvent<unknown>) => void,
+    onKeyDown: rest.onKeyDown as (e: React.KeyboardEvent<unknown>) => void,
+    onFocus: rest.onFocus as (e: React.FocusEvent<unknown>) => void,
+    onInvalid: rest.onInvalid as (e: React.InvalidEvent<unknown>) => void,
+    inputRef: (inputElement: HTMLInputElement) => {
+      if (typeof ref === 'function') {
+        ref(inputElement)
+      } else if (ref) {
+        ref.current = inputElement
+      }
+    },
+    ...bs3Props,
+  }
+
+  // get all `aria-*` and `data-*` attributes
+  const extraProps = Object.entries(rest).reduce(
+    (acc, [key, value]) => {
+      if (key.startsWith('aria-') || key.startsWith('data-')) {
+        acc[key] = value
+      }
+      return acc
+    },
+    {} as Record<string, string>
+  )
+
+  bs3FormControlProps = {
+    ...bs3FormControlProps,
+    ...extraProps,
+    'data-ol-dirty': rest['data-ol-dirty'],
+  } as typeof bs3FormControlProps & Record<string, unknown>
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3FormControl {...bs3FormControlProps} />}
+      bs5={<Form.Control ref={ref} {...rest} />}
+    />
+  )
+})
+FormControlWrapper.displayName = 'FormControlWrapper'
+
+export default FormControlWrapper

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

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

+ 27 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-label-wrapper.tsx

@@ -0,0 +1,27 @@
+import { Form } from 'react-bootstrap-5'
+import { ControlLabel as BS3FormLabel } from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+type FormLabelWrapperProps = React.ComponentProps<(typeof Form)['Label']> & {
+  bs3Props?: Record<string, unknown>
+}
+
+function FormLabelWrapper(props: FormLabelWrapperProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3FormLabelProps: React.ComponentProps<typeof BS3FormLabel> = {
+    children: rest.children,
+    htmlFor: rest.htmlFor,
+    srOnly: rest.visuallyHidden,
+    ...bs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3FormLabel {...bs3FormLabelProps} />}
+      bs5={<Form.Label {...rest} />}
+    />
+  )
+}
+
+export default FormLabelWrapper

+ 38 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-text-wrapper.tsx

@@ -0,0 +1,38 @@
+import FormText, {
+  getFormTextColor,
+} from '@/features/ui/components/bootstrap-5/form/form-text'
+import PolymorphicComponent from '@/shared/components/polymorphic-component'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import classnames from 'classnames'
+
+type FormTextWrapperProps = React.ComponentProps<typeof FormText> & {
+  bs3Props?: Record<string, unknown>
+}
+
+function FormTextWrapper(props: FormTextWrapperProps) {
+  const { bs3Props, ...rest } = props
+
+  const bs3HelpBlockProps = {
+    children: rest.children,
+    className: classnames(
+      'small',
+      rest.className,
+      getFormTextColor({
+        isError: rest.isError,
+        isSuccess: rest.isSuccess,
+        isWarning: rest.isWarning,
+      })
+    ),
+    as: 'span',
+    ...bs3Props,
+  } as const satisfies React.ComponentProps<typeof PolymorphicComponent>
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<PolymorphicComponent {...bs3HelpBlockProps} />}
+      bs5={<FormText {...rest} />}
+    />
+  )
+}
+
+export default FormTextWrapper

+ 65 - 0
services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx

@@ -0,0 +1,65 @@
+import { useRef, useLayoutEffect } from 'react'
+import { Form } from 'react-bootstrap-5'
+import type { Meta, StoryObj } from '@storybook/react'
+
+const meta: Meta<(typeof Form)['Check']> = {
+  title: 'Shared / Components / Bootstrap 5 / Form',
+  component: Form.Check,
+  parameters: {
+    bootstrap5: true,
+  },
+  argTypes: {
+    id: {
+      table: {
+        disable: true,
+      },
+    },
+    label: {
+      table: {
+        disable: true,
+      },
+    },
+    defaultChecked: {
+      table: {
+        disable: true,
+      },
+    },
+  },
+}
+export default meta
+
+type Story = StoryObj<(typeof Form)['Check']>
+
+export const Checkbox: Story = {
+  args: {
+    id: 'id-1',
+    label: 'Label',
+    disabled: false,
+  },
+}
+
+export const CheckboxChecked: Story = {
+  args: {
+    id: 'id-1',
+    label: 'Label',
+    disabled: false,
+    defaultChecked: true,
+  },
+}
+
+export const CheckboxIndeterminate = (args: Story['args']) => {
+  const ref = useRef<HTMLInputElement>()
+
+  useLayoutEffect(() => {
+    if (ref.current) {
+      ref.current.indeterminate = true
+    }
+  }, [])
+
+  return <Form.Check ref={ref} {...args} />
+}
+CheckboxIndeterminate.args = {
+  id: 'id-2',
+  label: 'Label',
+  disabled: false,
+}

+ 206 - 0
services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx

@@ -0,0 +1,206 @@
+import { Form } from 'react-bootstrap-5'
+import type { Meta, StoryObj } from '@storybook/react'
+import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
+
+const meta: Meta<(typeof Form)['Control']> = {
+  title: 'Shared / Components / Bootstrap 5 / Form / Input',
+  component: Form.Control,
+  parameters: {
+    bootstrap5: true,
+  },
+}
+export default meta
+
+type Story = StoryObj<(typeof Form)['Control']>
+
+export const Default: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control defaultValue="Large input" size="lg" {...args} />
+          <FormText>Helper</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control defaultValue="Regular input" {...args} />
+          <FormText>Helper</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control defaultValue="Small input" size="sm" {...args} />
+          <FormText>Helper</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+Default.args = {
+  disabled: false,
+}
+
+export const Info: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Large input"
+            size="lg"
+            {...args}
+          />
+          <FormText isInfo>Info</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Regular input"
+            {...args}
+          />
+          <FormText isInfo>Info</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Small input"
+            size="sm"
+            {...args}
+          />
+          <FormText isInfo>Info</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+
+export const Error: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Large input"
+            size="lg"
+            isInvalid
+            {...args}
+          />
+          <FormText isError>Error</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Regular input"
+            isInvalid
+            {...args}
+          />
+          <FormText isError>Error</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Small input"
+            size="sm"
+            isInvalid
+            {...args}
+          />
+          <FormText isError>Error</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+
+export const Warning: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Large input"
+            size="lg"
+            {...args}
+          />
+          <FormText isWarning>Warning</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Regular input"
+            {...args}
+          />
+          <FormText isWarning>Warning</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Small input"
+            size="sm"
+            {...args}
+          />
+          <FormText isWarning>Warning</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+
+export const Success: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Large input"
+            size="lg"
+            {...args}
+          />
+          <FormText isSuccess>Success</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Regular input"
+            {...args}
+          />
+          <FormText isSuccess>Success</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            placeholder="Placeholder"
+            defaultValue="Small input"
+            size="sm"
+            {...args}
+          />
+          <FormText isSuccess>Success</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}

+ 54 - 0
services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx

@@ -0,0 +1,54 @@
+import { Form } from 'react-bootstrap-5'
+import type { Meta, StoryObj } from '@storybook/react'
+
+const meta: Meta<(typeof Form)['Check']> = {
+  title: 'Shared / Components / Bootstrap 5 / Form',
+  component: Form.Check,
+  parameters: {
+    bootstrap5: true,
+  },
+  argTypes: {
+    id: {
+      table: {
+        disable: true,
+      },
+    },
+    label: {
+      table: {
+        disable: true,
+      },
+    },
+    type: {
+      table: {
+        disable: true,
+      },
+    },
+    defaultChecked: {
+      table: {
+        disable: true,
+      },
+    },
+  },
+}
+export default meta
+
+type Story = StoryObj<(typeof Form)['Check']>
+
+export const Radio: Story = {
+  args: {
+    id: 'id-1',
+    type: 'radio',
+    label: 'Label',
+    disabled: false,
+  },
+}
+
+export const RadioChecked: Story = {
+  args: {
+    id: 'id-1',
+    type: 'radio',
+    label: 'Label',
+    disabled: false,
+    defaultChecked: true,
+  },
+}

+ 222 - 0
services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx

@@ -0,0 +1,222 @@
+import { Form } from 'react-bootstrap-5'
+import type { Meta, StoryObj } from '@storybook/react'
+import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
+
+const meta: Meta<(typeof Form)['Select']> = {
+  title: 'Shared / Components / Bootstrap 5 / Form / Select',
+  component: Form.Select,
+  parameters: {
+    bootstrap5: true,
+  },
+}
+export default meta
+
+type Story = StoryObj<(typeof Form)['Select']>
+
+export const Default: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <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>Helper</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <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>Helper</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <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>Helper</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+Default.args = {
+  disabled: false,
+}
+
+export const Info: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <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 isInfo>Info</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <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 isInfo>Info</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <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 isInfo>Info</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+
+export const Error: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Select size="lg" isInvalid {...args}>
+            <option>Large select</option>
+            <option value="1">One</option>
+            <option value="2">Two</option>
+            <option value="3">Three</option>
+          </Form.Select>
+          <FormText isError>Error</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Select isInvalid {...args}>
+            <option>Regular select</option>
+            <option value="1">One</option>
+            <option value="2">Two</option>
+            <option value="3">Three</option>
+          </Form.Select>
+          <FormText isError>Error</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Select size="sm" isInvalid {...args}>
+            <option>Small select</option>
+            <option value="1">One</option>
+            <option value="2">Two</option>
+            <option value="3">Three</option>
+          </Form.Select>
+          <FormText isError>Error</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+
+export const Warning: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <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 isWarning>Warning</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <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 isWarning>Warning</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <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 isWarning>Warning</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+
+export const Success: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Select size="lg" isSuccess {...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>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Select isSuccess {...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>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Select size="sm" isSuccess {...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>
+      </>
+    )
+  },
+}

+ 228 - 0
services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx

@@ -0,0 +1,228 @@
+import { Form } from 'react-bootstrap-5'
+import type { Meta, StoryObj } from '@storybook/react'
+import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
+
+const meta: Meta<(typeof Form)['Control']> = {
+  title: 'Shared / Components / Bootstrap 5 / Form / Textarea',
+  component: Form.Control,
+  parameters: {
+    bootstrap5: true,
+  },
+}
+export default meta
+
+type Story = StoryObj<(typeof Form)['Control']>
+
+export const Default: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            defaultValue="Large input"
+            size="lg"
+            {...args}
+          />
+          <FormText>Helper</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control as="textarea" defaultValue="Regular input" {...args} />
+          <FormText>Helper</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            defaultValue="Small input"
+            size="sm"
+            {...args}
+          />
+          <FormText>Helper</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+Default.args = {
+  disabled: false,
+}
+
+export const Info: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Large input"
+            size="lg"
+            {...args}
+          />
+          <FormText isInfo>Info</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Regular input"
+            {...args}
+          />
+          <FormText isInfo>Info</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Small input"
+            size="sm"
+            {...args}
+          />
+          <FormText isInfo>Info</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+
+export const Error: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Large input"
+            size="lg"
+            isInvalid
+            {...args}
+          />
+          <FormText isError>Error</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Regular input"
+            isInvalid
+            {...args}
+          />
+          <FormText isError>Error</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Small input"
+            size="sm"
+            isInvalid
+            {...args}
+          />
+          <FormText isError>Error</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+
+export const Warning: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Large input"
+            size="lg"
+            {...args}
+          />
+          <FormText isWarning>Warning</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Regular input"
+            {...args}
+          />
+          <FormText isWarning>Warning</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Small input"
+            size="sm"
+            {...args}
+          />
+          <FormText isWarning>Warning</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}
+
+export const Success: Story = {
+  render: args => {
+    return (
+      <>
+        <Form.Group controlId="id-1">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Large input"
+            size="lg"
+            {...args}
+          />
+          <FormText isSuccess>Success</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-2">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Regular input"
+            {...args}
+          />
+          <FormText isSuccess>Success</FormText>
+        </Form.Group>
+        <hr />
+        <Form.Group controlId="id-3">
+          <Form.Label>Label</Form.Label>
+          <Form.Control
+            as="textarea"
+            placeholder="Placeholder"
+            defaultValue="Small input"
+            size="sm"
+            {...args}
+          />
+          <FormText isSuccess>Success</FormText>
+        </Form.Group>
+      </>
+    )
+  },
+}

+ 111 - 0
services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss

@@ -2,6 +2,12 @@
 
 $prefix: bs-;
 
+// Options
+//
+// Quickly modify global styling by enabling or disabling optional features.
+
+$enable-validation-icons: false;
+
 // Fonts
 $font-family-sans-serif: 'Noto Sans', sans-serif;
 $font-family-serif: 'Merriweather', serif;
@@ -10,6 +16,15 @@ $font-size-base: 1rem;
 $font-size-sm: var(--font-size-02);
 $line-height-base: 1.5;
 
+// Components
+//
+// Define common padding and border radius sizes and more.
+
+$focus-ring-width: 2px;
+$focus-ring-opacity: 1;
+$focus-ring-color: $blue-30;
+$focus-ring-blur: 0;
+
 // Buttons
 $btn-font-family: $font-family-sans-serif;
 $btn-font-weight: 700;
@@ -20,9 +35,105 @@ $btn-border-radius-lg: $border-radius-full;
 $btn-border-radius-sm: $border-radius-full;
 $btn-white-space: nowrap;
 
+// Forms
+
+// form-text-variables
+$form-text-margin-top: $spacing-04;
+$form-text-font-size: var(--font-size-02);
+$form-text-color: var(--content-secondary);
+
+// form-label-variables
+$form-label-margin-bottom: $spacing-02;
+$form-label-font-size: var(--font-size-02);
+$form-label-font-weight: 600;
+$form-label-color: var(--content-secondary);
+
+// form-input-variables
+$input-padding-y: $spacing-03;
+$input-padding-x: $spacing-04;
+$input-font-size: var(--font-size-03);
+$input-line-height: 1.5; // equivalent of var(--line-height-03) - BS expects a unitless value for further calculations
+
+$input-padding-y-sm: $spacing-01;
+$input-padding-x-sm: $spacing-03;
+$input-font-size-sm: var(--font-size-02);
+$input-line-height-sm: 1.25; // equivalent of var(--line-height-02) - BS expects a unitless value for further calculations
+
+$input-padding-y-lg: $spacing-05;
+$input-padding-x-lg: $spacing-05;
+$input-font-size-lg: var(--font-size-03);
+
+$input-bg: $bg-light-primary;
+$input-disabled-color: var(--content-disabled);
+$input-disabled-bg: $bg-light-disabled;
+$input-disabled-border-color: var(--border-disabled);
+
+$input-color: $content-primary;
+$input-border-color: $neutral-60;
+$input-border-width: var(--bs-border-width);
+
+$input-border-radius: $border-radius-base;
+$input-border-radius-sm: $border-radius-base;
+$input-border-radius-lg: $border-radius-base;
+
+$input-focus-border-color: var(--border-active);
+
+$input-placeholder-color: var(--content-placeholder);
+$input-plaintext-color: $content-primary;
+
+$input-height-border: calc(
+  #{$input-border-width} * 2
+); // stylelint-disable-line function-disallowed-list
+
+$input-height: add(
+  $input-line-height * 1em,
+  add($input-padding-y * 2, $input-height-border, false)
+);
+$input-height-sm: add(
+  $input-line-height-sm * 1em,
+  add($input-padding-y-sm * 2, $input-height-border, false)
+);
+$input-height-lg: add(
+  $input-line-height * 1em,
+  add($input-padding-y-lg * 2, $input-height-border, false)
+);
+
+// form-check-variables
+$form-check-label-color: var(--content-primary);
+
+$form-check-input-border: var(--bs-border-width) solid var(--border-primary);
+$form-check-input-border-radius: $border-radius-base;
+$form-check-input-focus-border: var(--border-primary);
+
+$form-check-input-disabled-opacity: 1;
+
+// Form validation
+
+// form-feedback-variables
+$form-feedback-invalid-color: $bg-danger-01;
+$form-feedback-icon-invalid: null;
+
+// form-validation-colors
+$form-invalid-color: $form-feedback-invalid-color;
+$form-invalid-border-color: $bg-danger-01;
+
+// form-validation-states
+$form-validation-states: (
+  'invalid': (
+    'color': $bg-danger-01,
+    'icon': $form-feedback-icon-invalid,
+    'tooltip-color': #fff,
+    'tooltip-bg-color': var(--bs-danger),
+    'focus-box-shadow': 0 0 $focus-ring-blur $focus-ring-width
+      rgba($red-30, $focus-ring-opacity),
+    'border-color': var(--#{$prefix}form-invalid-border-color),
+  ),
+);
+
 // Colors
 $primary: $bg-accent-01;
 $secondary: $bg-light-primary;
+$success: $bg-accent-01;
 $info: $bg-info-01;
 $warning: $bg-warning-01;
 $danger: $bg-danger-01;

+ 4 - 0
services/web/frontend/stylesheets/bootstrap-5/base/base.scss

@@ -0,0 +1,4 @@
+.grecaptcha-badge {
+  visibility: hidden;
+  height: 0 !important; // Prevent layout shift
+}

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/base/bootstrap.scss

@@ -26,6 +26,7 @@
 @import 'bootstrap-5/scss/images';
 @import 'bootstrap-5/scss/containers';
 @import 'bootstrap-5/scss/grid';
+@import 'bootstrap-5/scss/forms';
 @import 'bootstrap-5/scss/buttons';
 @import 'bootstrap-5/scss/dropdown';
 @import 'bootstrap-5/scss/badge';

+ 2 - 0
services/web/frontend/stylesheets/bootstrap-5/components/all.scss

@@ -5,4 +5,6 @@
 @import 'tooltip';
 @import 'card';
 @import 'badge';
+@import 'form';
+@import 'input-suggestions';
 @import 'footer';

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

@@ -0,0 +1,82 @@
+.form-check-input {
+  @function form-check-box-svg($color) {
+    @return url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='none' stroke='#{$color}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/></svg>");
+  }
+  @function form-check-radio-svg($color) {
+    @return url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='2' fill='#{$color}'/></svg>");
+  }
+  @function form-check-indeterminate-svg($color) {
+    @return url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='none' stroke='#{$color}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/></svg>");
+  }
+
+  &:hover {
+    &:not(:disabled) {
+      border-color: var(--border-hover);
+
+      &:checked,
+      &[type='checkbox']:indeterminate {
+        background-color: var(--bg-accent-02);
+        border-color: var(--bg-accent-02);
+      }
+    }
+  }
+
+  &:disabled {
+    @mixin input-disabled-styles {
+      background-color: $input-disabled-bg;
+      border-color: $input-disabled-border-color;
+    }
+
+    @include input-disabled-styles;
+
+    &[type='checkbox']:indeterminate {
+      @include input-disabled-styles;
+
+      background-image: escape-svg(form-check-indeterminate-svg($neutral-40));
+    }
+  }
+
+  // Use disabled attribute in addition of :disabled pseudo-class
+  // See: https://github.com/twbs/bootstrap/issues/28247
+  &[disabled],
+  &:disabled {
+    ~ .form-check-label {
+      color: $input-disabled-color;
+    }
+
+    &:checked {
+      &[type='checkbox'] {
+        background-image: escape-svg(form-check-box-svg($neutral-40));
+      }
+
+      &[type='radio'] {
+        background-image: escape-svg(form-check-radio-svg($neutral-40));
+      }
+    }
+  }
+}
+
+.form-control {
+  &[disabled],
+  &:disabled {
+    &::placeholder {
+      color: var(--content-disabled);
+    }
+  }
+}
+
+.form-text {
+  display: inline-flex;
+  gap: $spacing-02;
+  line-height: var(--line-height-02);
+
+  .form-control[disabled] ~ & {
+    color: var(--content-disabled);
+  }
+}
+
+.form-label {
+  &:has(+ .form-control[disabled]) {
+    color: var(--content-disabled);
+  }
+}

+ 20 - 0
services/web/frontend/stylesheets/bootstrap-5/components/input-suggestions.scss

@@ -0,0 +1,20 @@
+.input-suggestions {
+  position: relative;
+}
+
+.input-suggestions-main {
+  position: absolute;
+  top: 0;
+  background-color: transparent;
+}
+
+.input-suggestions-shadow {
+  background-color: $input-bg;
+  color: var(--content-placeholder);
+
+  & + .input-suggestions-main {
+    &:focus {
+      background-color: transparent;
+    }
+  }
+}

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/main-style.scss

@@ -28,6 +28,7 @@ $is-overleaf-light: false;
 
 // Link styles
 @import 'base/links';
+@import 'base/base';
 
 // Page layout that isn't related to a particular component or page
 @import 'base/layout';

+ 2 - 9
services/web/frontend/stylesheets/components/input-suggestions.less

@@ -1,7 +1,7 @@
 .input-suggestions {
   position: relative;
-  height: @input-height-base;
 }
+
 .input-suggestions-main {
   position: absolute;
   top: 0;
@@ -9,13 +9,6 @@
 }
 
 .input-suggestions-shadow {
-  background-color: @input-bg;
-  padding-top: @input-suggestion-v-offset;
-}
-.input-suggestions-shadow-existing {
-  color: transparent;
-}
-
-.input-suggestions-shadow-suggested {
+  background-color: @input-bg !important;
   color: lighten(@input-color, 25%);
 }

+ 109 - 47
services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx

@@ -1,9 +1,4 @@
-import {
-  fireEvent,
-  render,
-  screen,
-  waitForElementToBeRemoved,
-} from '@testing-library/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { expect } from 'chai'
 import sinon from 'sinon'
 import fetchMock from 'fetch-mock'
@@ -55,19 +50,19 @@ describe('<AddEmailInput/>', function () {
           handleAddNewEmail={handleAddNewEmailStub}
         />
       )
-      fireEvent.change(screen.getByRole('textbox'), {
+      fireEvent.change(screen.getByTestId('affiliations-email'), {
         target: { value: 'user' },
       })
     })
 
     it('should render the text being typed', function () {
-      const input = screen.getByRole('textbox') as HTMLInputElement
+      const input = screen.getByTestId('affiliations-email') as HTMLInputElement
       expect(input.value).to.equal('user')
     })
 
     it('should dispatch a `change` event on every stroke', function () {
       expect(onChangeStub.calledWith('user')).to.equal(true)
-      fireEvent.change(screen.getByRole('textbox'), {
+      fireEvent.change(screen.getByTestId('affiliations-email'), {
         target: { value: 's' },
       })
       expect(onChangeStub.calledWith('s')).to.equal(true)
@@ -78,18 +73,22 @@ describe('<AddEmailInput/>', function () {
     })
 
     it('should submit on Enter if email looks valid', async function () {
-      fireEvent.change(screen.getByRole('textbox'), {
+      fireEvent.change(screen.getByTestId('affiliations-email'), {
         target: { value: 'user@domain.com' },
       })
-      fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
+      fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
+        key: 'Enter',
+      })
       expect(handleAddNewEmailStub.calledWith()).to.equal(true)
     })
 
     it('should not submit on Enter if email does not look valid', async function () {
-      fireEvent.change(screen.getByRole('textbox'), {
+      fireEvent.change(screen.getByTestId('affiliations-email'), {
         target: { value: 'user@' },
       })
-      fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
+      fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
+        key: 'Enter',
+      })
       expect(handleAddNewEmailStub.calledWith()).to.equal(false)
     })
   })
@@ -105,13 +104,15 @@ describe('<AddEmailInput/>', function () {
     describe('when there are no matches', function () {
       beforeEach(function () {
         fetchMock.get('express:/institutions/domains', 200)
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: 'user@d' },
         })
       })
 
       it('should render the text being typed', function () {
-        const input = screen.getByRole('textbox') as HTMLInputElement
+        const input = screen.getByTestId(
+          'affiliations-email'
+        ) as HTMLInputElement
         expect(input.value).to.equal('user@d')
       })
     })
@@ -119,15 +120,22 @@ describe('<AddEmailInput/>', function () {
     describe('when there is a domain match', function () {
       beforeEach(function () {
         fetchMock.get('express:/institutions/domains', testInstitutionData)
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: 'user@d' },
         })
       })
 
       it('should render the text being typed along with the suggestion', async function () {
-        const input = screen.getByRole('textbox') as HTMLInputElement
+        const input = screen.getByTestId(
+          'affiliations-email'
+        ) as HTMLInputElement
         expect(input.value).to.equal('user@d')
-        await screen.findByText('user@domain.edu')
+        await waitFor(() => {
+          const shadowInput = screen.getByTestId(
+            'affiliations-email-shadow'
+          ) as HTMLInputElement
+          expect(shadowInput.value).to.equal('user@domain.edu')
+        })
       })
 
       it('should dispatch a `change` event with the typed text', function () {
@@ -135,7 +143,7 @@ describe('<AddEmailInput/>', function () {
       })
 
       it('should dispatch a `change` event with institution data when the typed email contains the institution domain', async function () {
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: 'user@domain.edu' },
         })
         await fetchMock.flush(true)
@@ -148,8 +156,13 @@ describe('<AddEmailInput/>', function () {
       })
 
       it('should clear the suggestion when the potential domain match is completely deleted', async function () {
-        await screen.findByText('user@domain.edu')
-        fireEvent.change(screen.getByRole('textbox'), {
+        await waitFor(() => {
+          const shadowInput = screen.getByTestId(
+            'affiliations-email-shadow'
+          ) as HTMLInputElement
+          expect(shadowInput.value).to.equal('user@domain.edu')
+        })
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: '' },
         })
         expect(screen.queryByText('user@domain.edu')).to.be.null
@@ -157,12 +170,22 @@ describe('<AddEmailInput/>', function () {
 
       describe('when there is a suggestion and "Tab" key is pressed', function () {
         beforeEach(async function () {
-          await screen.findByText('user@domain.edu') // wait until autocompletion available
-          fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' })
+          // wait until autocompletion available
+          await waitFor(() => {
+            const shadowInput = screen.getByTestId(
+              'affiliations-email-shadow'
+            ) as HTMLInputElement
+            expect(shadowInput.value).to.equal('user@domain.edu')
+          })
+          fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
+            key: 'Tab',
+          })
         })
 
         it('it should autocomplete the input', async function () {
-          const input = screen.getByRole('textbox') as HTMLInputElement
+          const input = screen.getByTestId(
+            'affiliations-email'
+          ) as HTMLInputElement
           expect(input.value).to.equal('user@domain.edu')
         })
 
@@ -178,12 +201,22 @@ describe('<AddEmailInput/>', function () {
 
       describe('when there is a suggestion and "Enter" key is pressed', function () {
         beforeEach(async function () {
-          await screen.findByText('user@domain.edu') // wait until autocompletion available
-          fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
+          // wait until autocompletion available
+          await waitFor(() => {
+            const shadowInput = screen.getByTestId(
+              'affiliations-email-shadow'
+            ) as HTMLInputElement
+            expect(shadowInput.value).to.equal('user@domain.edu')
+          })
+          fireEvent.keyDown(screen.getByTestId('affiliations-email'), {
+            key: 'Enter',
+          })
         })
 
         it('it should autocomplete the input', async function () {
-          const input = screen.getByRole('textbox') as HTMLInputElement
+          const input = screen.getByTestId(
+            'affiliations-email'
+          ) as HTMLInputElement
           expect(input.value).to.equal('user@domain.edu')
         })
 
@@ -201,17 +234,22 @@ describe('<AddEmailInput/>', function () {
         fetchMock.reset()
 
         // clear input
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: '' },
         })
         // type a hint to trigger the domain search
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: 'user@d' },
         })
 
         expect(fetchMock.called()).to.be.false
         expect(onChangeStub.calledWith('user@d')).to.equal(true)
-        await screen.findByText('user@domain.edu')
+        await waitFor(() => {
+          const shadowInput = screen.getByTestId(
+            'affiliations-email-shadow'
+          ) as HTMLInputElement
+          expect(shadowInput.value).to.equal('user@domain.edu')
+        })
       })
     })
 
@@ -228,7 +266,7 @@ describe('<AddEmailInput/>', function () {
           { university: { id: 1 }, hostname: blockedDomain },
         ]
         fetchMock.get('express:/institutions/domains', blockedInstitution)
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: `user@${blockedDomain.split('.')[0]}` },
         })
         await fetchMock.flush(true)
@@ -243,7 +281,7 @@ describe('<AddEmailInput/>', function () {
           },
         ]
         fetchMock.get('express:/institutions/domains', blockedInstitution)
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: {
             value: `user@subdomain.${blockedDomain.split('.')[0]}`,
           },
@@ -257,10 +295,15 @@ describe('<AddEmailInput/>', function () {
       beforeEach(async function () {
         // type an initial suggestion
         fetchMock.get('express:/institutions/domains', testInstitutionData)
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: 'user@d' },
         })
-        await screen.findByText('user@domain.edu')
+        await waitFor(() => {
+          const shadowInput = screen.getByTestId(
+            'affiliations-email-shadow'
+          ) as HTMLInputElement
+          expect(shadowInput.value).to.equal('user@domain.edu')
+        })
 
         // make sure the next suggestions are delayed
         clearDomainCache()
@@ -269,17 +312,25 @@ describe('<AddEmailInput/>', function () {
       })
 
       it('should keep the suggestion if the hint matches the previously matched domain', async function () {
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: 'user@do' },
         })
-        screen.getByText('user@domain.edu')
+        const shadowInput = screen.getByTestId(
+          'affiliations-email-shadow'
+        ) as HTMLInputElement
+        expect(shadowInput.value).to.equal('user@domain.edu')
       })
 
       it('should remove the suggestion if the hint does not match the previously matched domain', async function () {
-        fireEvent.change(screen.getByRole('textbox'), {
+        fireEvent.change(screen.getByTestId('affiliations-email'), {
           target: { value: 'user@foo' },
         })
-        expect(screen.queryByText('user@domain.edu')).to.be.null
+        await waitFor(() => {
+          const shadowInput = screen.getByTestId(
+            'affiliations-email-shadow'
+          ) as HTMLInputElement
+          expect(shadowInput.value).to.equal('')
+        })
       })
     })
   })
@@ -292,10 +343,15 @@ describe('<AddEmailInput/>', function () {
       fetchMock.get('express:/institutions/domains', testInstitutionData)
       onChangeStub = sinon.stub()
       render(<Input {...defaultProps} onChange={onChangeStub} />)
-      fireEvent.change(screen.getByRole('textbox'), {
+      fireEvent.change(screen.getByTestId('affiliations-email'), {
         target: { value: 'user@d' },
       })
-      await screen.findByText('user@domain.edu')
+      await waitFor(() => {
+        const shadowInput = screen.getByTestId(
+          'affiliations-email-shadow'
+        ) as HTMLInputElement
+        expect(shadowInput.value).to.equal('user@domain.edu')
+      })
 
       // subsequent requests fail
       fetchMock.reset()
@@ -303,16 +359,19 @@ describe('<AddEmailInput/>', function () {
     })
 
     it('should clear suggestions', async function () {
-      fireEvent.change(screen.getByRole('textbox'), {
+      fireEvent.change(screen.getByTestId('affiliations-email'), {
         target: { value: 'user@dom' },
       })
 
-      const input = screen.getByRole('textbox') as HTMLInputElement
+      const input = screen.getByTestId('affiliations-email') as HTMLInputElement
       expect(input.value).to.equal('user@dom')
 
-      await waitForElementToBeRemoved(() =>
-        screen.queryByText('user@domain.edu')
-      )
+      await waitFor(() => {
+        const shadowInput = screen.getByTestId(
+          'affiliations-email-shadow'
+        ) as HTMLInputElement
+        expect(shadowInput.value).to.equal('')
+      })
 
       expect(fetchMock.called()).to.be.true // ensures `domainCache` hasn't been hit
     })
@@ -322,11 +381,14 @@ describe('<AddEmailInput/>', function () {
     it('should clear suggestion', async function () {
       fetchMock.get('express:/institutions/domains', testInstitutionData)
       render(<Input {...defaultProps} onChange={sinon.stub()} />)
-      fireEvent.change(screen.getByRole('textbox'), {
+      fireEvent.change(screen.getByTestId('affiliations-email'), {
         target: { value: 'user@other' },
       })
       await fetchMock.flush(true)
-      expect(screen.queryByText('user@domain.edu')).to.not.exist
+      const shadowInput = screen.getByTestId(
+        'affiliations-email-shadow'
+      ) as HTMLInputElement
+      expect(shadowInput.value).to.equal('')
     })
   })
 })