diff --git a/services/web/frontend/js/features/settings/components/account-info-section.tsx b/services/web/frontend/js/features/settings/components/account-info-section.tsx index 4bbd9df24a..72b2598c6d 100644 --- a/services/web/frontend/js/features/settings/components/account-info-section.tsx +++ b/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 ? ( - + - + ) : null} {isError ? ( - + - + ) : null} {canUpdateEmail || canUpdateNames ? ( - ) => { + const handleInvalid = (event: React.InvalidEvent) => { event.preventDefault() } const handleChangeAndValidity = ( - event: React.ChangeEvent + event: React.ChangeEvent ) => { handleChange(event) setValidationMessage(event.target.validationMessage) @@ -174,18 +175,17 @@ function ReadOrWriteFormGroup({ if (!canEdit) { return ( - - {label} - - + + {label} + + ) } return ( - - {label} - + {label} + - {validationMessage ? ( - {validationMessage} - ) : null} - + {validationMessage && {validationMessage}} + ) } diff --git a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx b/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx index 3f2a518c32..e506a054c4 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx +++ b/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> @@ -62,7 +63,7 @@ function Downshift({ setValue, inputRef }: CountryInputProps) { > {t('country')} - ) => { setInputValue(event.target.value) @@ -74,8 +75,6 @@ function Downshift({ setValue, inputRef }: CountryInputProps) { }, ref: inputRef, })} - className="form-control" - type="text" placeholder={t('country')} /> diff --git a/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx b/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx index 478dc06812..60fd1201fd 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-email/input.tsx +++ b/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(null) + const inputRef = useRef(null) const [suggestion, setSuggestion] = useState(null) const [inputValue, setInputValue] = useState(null) const [matchedDomain, setMatchedDomain] = useState(null) @@ -161,15 +162,16 @@ function Input({ onChange, handleAddNewEmail }: InputProps) { return (
-
-
- {suggestion || ''} -
-
- - + -
+ -
-
+ -
+ )} {isRoleAndDepartmentVisible && ( <> -
+ -
-
+ + -
+ )} diff --git a/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx b/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx index cde10bb936..234fffae60 100644 --- a/services/web/frontend/js/features/settings/components/emails/downshift-input.tsx +++ b/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} - ) => { setValue(event.target.value) @@ -109,8 +110,6 @@ function Downshift({ }, ref: inputRef, })} - className="form-control" - type="text" placeholder={placeholder} disabled={disabled} /> diff --git a/services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx b/services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx index bd36dca917..f8e6d611a8 100644 --- a/services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx +++ b/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) { ) : (
-
+ -
-
+ + -
+ > @@ -26,15 +29,11 @@ function LeaveModalForm({ const [confirmation, setConfirmation] = useState(false) const [error, setError] = useState(null) - const handleEmailChange = ( - event: React.ChangeEvent - ) => { + const handleEmailChange = (event: React.ChangeEvent) => { setEmail(event.target.value) } - const handlePasswordChange = ( - event: React.ChangeEvent - ) => { + const handlePasswordChange = (event: React.ChangeEvent) => { setPassword(event.target.value) } @@ -74,43 +73,43 @@ function LeaveModalForm({ return ( - - {t('email')} - + {t('email')} + - - - {t('password')} - + + {t('password')} + - - + - ]} // eslint-disable-line react/jsx-key - values={{ - userDefaultEmail, - }} - shouldUnescape - tOptions={{ interpolation: { escapeValue: true } }} - /> - + label={ + ]} // eslint-disable-line react/jsx-key + values={{ + userDefaultEmail, + }} + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + /> + } + /> {error ? : null} ) diff --git a/services/web/frontend/js/features/settings/components/password-section.tsx b/services/web/frontend/js/features/settings/components/password-section.tsx index cd88333ae0..228f3597a9 100644 --- a/services/web/frontend/js/features/settings/components/password-section.tsx +++ b/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 ? ( - + - + ) : null} {isError ? ( - + - + ) : null} - ) => { + const handleInvalid = (event: React.InvalidEvent) => { event.preventDefault() } const handleChangeAndValidity = ( - event: React.ChangeEvent + event: React.ChangeEvent ) => { handleChange(event) setHadInteraction(true) setValidationMessage(event.target.validationMessage) } + const isInvalid = Boolean( + hadInteraction && (parentValidationMessage || validationMessage) + ) + return ( - - {label} - + {label} + - {hadInteraction && (parentValidationMessage || validationMessage) ? ( - + {isInvalid && ( + {parentValidationMessage || validationMessage} - - ) : null} - + + )} + ) } diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-text.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/form/form-text.tsx new file mode 100644 index 0000000000..8b804bfd04 --- /dev/null +++ b/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 ( + + {isInfo && } + {isError && } + {isWarning && } + {isSuccess && } + {children} + + ) +} + +export default FormText diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-checkbox-wrapper.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-checkbox-wrapper.tsx new file mode 100644 index 0000000000..3954a8c697 --- /dev/null +++ b/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 +} + +function FormCheckboxWrapper(props: FormCheckboxWrapperProps) { + const { bs3Props, ...rest } = props + + const bs3FormLabelProps: React.ComponentProps = { + 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) => void, + ...bs3Props, + } + + return ( + } + bs5={} + /> + ) +} + +export default FormCheckboxWrapper diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-control-wrapper.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-control-wrapper.tsx new file mode 100644 index 0000000000..6ad6da4b61 --- /dev/null +++ b/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 +} + +const FormControlWrapper = forwardRef< + HTMLInputElement, + FormControlWrapperProps +>((props, ref) => { + const { bs3Props, ...rest } = props + + let bs3FormControlProps: React.ComponentProps = { + 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) => void, + onKeyDown: rest.onKeyDown as (e: React.KeyboardEvent) => void, + onFocus: rest.onFocus as (e: React.FocusEvent) => void, + onInvalid: rest.onInvalid as (e: React.InvalidEvent) => 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 + ) + + bs3FormControlProps = { + ...bs3FormControlProps, + ...extraProps, + 'data-ol-dirty': rest['data-ol-dirty'], + } as typeof bs3FormControlProps & Record + + return ( + } + bs5={} + /> + ) +}) +FormControlWrapper.displayName = 'FormControlWrapper' + +export default FormControlWrapper diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-group-wrapper.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-group-wrapper.tsx new file mode 100644 index 0000000000..3defd4ac9f --- /dev/null +++ b/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 +} + +function FormGroupWrapper(props: FormGroupWrapperProps) { + const { bs3Props, className, ...rest } = props + + const classNames = className ?? 'mb-3' + + const bs3FormGroupProps: React.ComponentProps = { + children: rest.children, + controlId: rest.controlId, + className, + ...bs3Props, + } + + return ( + } + bs5={} + /> + ) +} + +export default FormGroupWrapper diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-label-wrapper.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-label-wrapper.tsx new file mode 100644 index 0000000000..3552fcd08e --- /dev/null +++ b/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 +} + +function FormLabelWrapper(props: FormLabelWrapperProps) { + const { bs3Props, ...rest } = props + + const bs3FormLabelProps: React.ComponentProps = { + children: rest.children, + htmlFor: rest.htmlFor, + srOnly: rest.visuallyHidden, + ...bs3Props, + } + + return ( + } + bs5={} + /> + ) +} + +export default FormLabelWrapper diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-text-wrapper.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/form-text-wrapper.tsx new file mode 100644 index 0000000000..87d7de476c --- /dev/null +++ b/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 & { + bs3Props?: Record +} + +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 + + return ( + } + bs5={} + /> + ) +} + +export default FormTextWrapper diff --git a/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-check-bs5.stories.tsx new file mode 100644 index 0000000000..0596aea5cd --- /dev/null +++ b/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() + + useLayoutEffect(() => { + if (ref.current) { + ref.current.indeterminate = true + } + }, []) + + return +} +CheckboxIndeterminate.args = { + id: 'id-2', + label: 'Label', + disabled: false, +} diff --git a/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx new file mode 100644 index 0000000000..4750e7df2b --- /dev/null +++ b/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 ( + <> + + Label + + Helper + +
+ + Label + + Helper + +
+ + Label + + Helper + + + ) + }, +} +Default.args = { + disabled: false, +} + +export const Info: Story = { + render: args => { + return ( + <> + + Label + + Info + +
+ + Label + + Info + +
+ + Label + + Info + + + ) + }, +} + +export const Error: Story = { + render: args => { + return ( + <> + + Label + + Error + +
+ + Label + + Error + +
+ + Label + + Error + + + ) + }, +} + +export const Warning: Story = { + render: args => { + return ( + <> + + Label + + Warning + +
+ + Label + + Warning + +
+ + Label + + Warning + + + ) + }, +} + +export const Success: Story = { + render: args => { + return ( + <> + + Label + + Success + +
+ + Label + + Success + +
+ + Label + + Success + + + ) + }, +} diff --git a/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-radio-bs5.stories.tsx new file mode 100644 index 0000000000..73e10b0bab --- /dev/null +++ b/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, + }, +} diff --git a/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx new file mode 100644 index 0000000000..c80187adf2 --- /dev/null +++ b/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 ( + <> + + Label + + + + + + + Helper + +
+ + Label + + + + + + + Helper + +
+ + Label + + + + + + + Helper + + + ) + }, +} +Default.args = { + disabled: false, +} + +export const Info: Story = { + render: args => { + return ( + <> + + Label + + + + + + + Info + +
+ + Label + + + + + + + Info + +
+ + Label + + + + + + + Info + + + ) + }, +} + +export const Error: Story = { + render: args => { + return ( + <> + + Label + + + + + + + Error + +
+ + Label + + + + + + + Error + +
+ + Label + + + + + + + Error + + + ) + }, +} + +export const Warning: Story = { + render: args => { + return ( + <> + + Label + + + + + + + Warning + +
+ + Label + + + + + + + Warning + +
+ + Label + + + + + + + Warning + + + ) + }, +} + +export const Success: Story = { + render: args => { + return ( + <> + + Label + + + + + + + Success + +
+ + Label + + + + + + + Success + +
+ + Label + + + + + + + Success + + + ) + }, +} diff --git a/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx b/services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx new file mode 100644 index 0000000000..2ac58f5d6b --- /dev/null +++ b/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 ( + <> + + Label + + Helper + +
+ + Label + + Helper + +
+ + Label + + Helper + + + ) + }, +} +Default.args = { + disabled: false, +} + +export const Info: Story = { + render: args => { + return ( + <> + + Label + + Info + +
+ + Label + + Info + +
+ + Label + + Info + + + ) + }, +} + +export const Error: Story = { + render: args => { + return ( + <> + + Label + + Error + +
+ + Label + + Error + +
+ + Label + + Error + + + ) + }, +} + +export const Warning: Story = { + render: args => { + return ( + <> + + Label + + Warning + +
+ + Label + + Warning + +
+ + Label + + Warning + + + ) + }, +} + +export const Success: Story = { + render: args => { + return ( + <> + + Label + + Success + +
+ + Label + + Success + +
+ + Label + + Success + + + ) + }, +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss b/services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss index f0cc28490b..8d99c0a2e9 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss +++ b/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; diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/base.scss b/services/web/frontend/stylesheets/bootstrap-5/base/base.scss new file mode 100644 index 0000000000..64ae509a40 --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/base/base.scss @@ -0,0 +1,4 @@ +.grecaptcha-badge { + visibility: hidden; + height: 0 !important; // Prevent layout shift +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/base/bootstrap.scss b/services/web/frontend/stylesheets/bootstrap-5/base/bootstrap.scss index 70b0321886..c48ed5e4d5 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/base/bootstrap.scss +++ b/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'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss index 96f5b86f10..24c499e401 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss +++ b/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'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/form.scss b/services/web/frontend/stylesheets/bootstrap-5/components/form.scss new file mode 100644 index 0000000000..2e1fafd4b8 --- /dev/null +++ b/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,"); + } + @function form-check-radio-svg($color) { + @return url("data:image/svg+xml,"); + } + @function form-check-indeterminate-svg($color) { + @return url("data:image/svg+xml,"); + } + + &: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); + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/input-suggestions.scss b/services/web/frontend/stylesheets/bootstrap-5/components/input-suggestions.scss new file mode 100644 index 0000000000..62c8ea9721 --- /dev/null +++ b/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; + } + } +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/main-style.scss b/services/web/frontend/stylesheets/bootstrap-5/main-style.scss index a2eb32aea7..7f9855ca96 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/main-style.scss +++ b/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'; diff --git a/services/web/frontend/stylesheets/components/input-suggestions.less b/services/web/frontend/stylesheets/components/input-suggestions.less index 53ee1fe945..7043e36e78 100644 --- a/services/web/frontend/stylesheets/components/input-suggestions.less +++ b/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%); } diff --git a/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx b/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx index 2f34c84e6a..c7707d9efb 100644 --- a/services/web/test/frontend/features/settings/components/emails/add-email-input.test.tsx +++ b/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('', 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('', 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('', 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('', 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('', 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('', 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('', 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('', 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('', 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('', 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('', 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('', 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('', 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('', function () { fetchMock.get('express:/institutions/domains', testInstitutionData) onChangeStub = sinon.stub() render() - 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('', 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('', function () { it('should clear suggestion', async function () { fetchMock.get('express:/institutions/domains', testInstitutionData) render() - 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('') }) }) })