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

[web] Bootstrap 5 form elements

GitOrigin-RevId: 7d031bed07007d0aa00a43f06d25bfb7384dee87
This commit is contained in:
ilkin-overleaf 2024-05-14 16:09:56 +03:00 committed by Copybot
parent b6093848af
commit 482bd7fb9c
28 changed files with 1478 additions and 154 deletions

View file

@ -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>
)
}

View file

@ -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" />

View file

@ -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}

View file

@ -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>
</>
)}
</>

View file

@ -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}
/>

View file

@ -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"

View file

@ -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,33 +73,32 @@ 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}
>
label={
<Trans
i18nKey="delete_account_confirmation_label"
components={[<i />]} // eslint-disable-line react/jsx-key
@ -110,7 +108,8 @@ function LeaveModalForm({
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</Checkbox>
}
/>
{error ? <LeaveModalFormError error={error} /> : null}
</form>
)

View file

@ -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>
)
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
}

View file

@ -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>
</>
)
},
}

View file

@ -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,
},
}

View file

@ -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>
</>
)
},
}

View file

@ -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>
</>
)
},
}

View file

@ -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;

View file

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

View file

@ -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';

View file

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

View file

@ -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);
}
}

View file

@ -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;
}
}
}

View file

@ -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';

View file

@ -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%);
}

View file

@ -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('')
})
})
})