e.preventDefault()}>
+ onChange(unitFieldName, option?.value || null)}
+ value={unitOptions.find(({ value }) => value === unit)}
+ styles={{ ...(reactSelectStyles as any) }}
+ isDisabled={disabled}
+ onBlur={onBlur}
+ formatOptionLabel={formatOptionLabel}
+ />
+
+ ) : (
+ {unit}
+ )
+ }
+ />
+
+ );
+};
+
+export default NumberInputWithSelect;
diff --git a/packages/webapp/src/components/Form/CompositionInputs/index.tsx b/packages/webapp/src/components/Form/CompositionInputs/index.tsx
new file mode 100644
index 0000000000..e8888e0270
--- /dev/null
+++ b/packages/webapp/src/components/Form/CompositionInputs/index.tsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import { BsFillExclamationCircleFill } from 'react-icons/bs';
+import InputBaseLabel from '../InputBase/InputBaseLabel';
+import NumberInputWithSelect, { NumberInputWithSelectProps } from './NumberInputWithSelect';
+import styles from './styles.module.scss';
+
+type CompositionInputsProps = Omit<
+ NumberInputWithSelectProps,
+ 'name' | 'label' | 'value' | 'unit'
+> & {
+ mainLabel?: string;
+ inputsInfo: { name: string; label: string }[];
+ values: { [key: string]: any };
+ unit?: string;
+ shouldShowErrorMessage?: boolean;
+};
+
+/**
+ * Component for inputs that share the same unit.
+ * Changing the unit of one input updates the units of all inputs.
+ * Units that require unit system conversions are not supported.
+ */
+const CompositionInputs = ({
+ mainLabel = '',
+ inputsInfo,
+ error = '',
+ shouldShowErrorMessage = true,
+ disabled = false,
+ onChange,
+ onBlur,
+ values,
+ unit,
+ unitFieldName = '',
+ ...props
+}: CompositionInputsProps) => {
+ return (
+
+ );
+};
+
+export default CompositionInputs;
diff --git a/packages/webapp/src/components/Form/CompositionInputs/styles.module.scss b/packages/webapp/src/components/Form/CompositionInputs/styles.module.scss
new file mode 100644
index 0000000000..5e64a8c5c2
--- /dev/null
+++ b/packages/webapp/src/components/Form/CompositionInputs/styles.module.scss
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+@import '../../../assets/mixin';
+
+/*----------------------------------------
+ NumberInputWithSelect
+----------------------------------------*/
+
+.inputWithSelectWrapper {
+ &:focus-within {
+ > div:nth-child(2) svg {
+ color: var(--Colors-Neutral-Neutral-600);
+ }
+ }
+
+ &.hasError {
+ input {
+ color: var(--Colors-Accent---singles-Red-full);
+ }
+
+ &:not(:focus-within) .selectWrapper {
+ border-color: var(--Colors-Accent---singles-Red-full);
+ }
+ }
+
+ &.disabled {
+ input {
+ color: var(--Colors-Neutral-Neutral-300);
+ @include truncateText();
+ }
+ }
+
+ &:not(.disabled) .selectWrapper {
+ margin-right: -8px;
+ border-left: 1px solid var(--grey400);
+ }
+}
+
+.ratioIcon {
+ margin-top: 4px;
+}
+
+/*----------------------------------------
+ Composition inputs
+----------------------------------------*/
+.compositionInputsWrapper {
+ @include flex-column-gap(12px);
+}
+
+.inputsWrapper {
+ display: flex;
+ gap: 24px;
+ flex-wrap: wrap;
+
+ > div {
+ flex: 1 0 31%; // align up to 3 items in a row
+ }
+
+ @include md-breakpoint {
+ flex-direction: column;
+ }
+}
+
+.selectValue {
+ color: var(--Colors-Neutral-Neutral-600, #5d697e);
+}
+
+.error {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--Colors-Accent---singles-Red-full);
+ justify-content: center;
+ margin-top: 8px;
+
+ .errorIcon {
+ width: 20px;
+ }
+
+ @include md-breakpoint {
+ .errorMessage {
+ flex: 1;
+ }
+ }
+}
diff --git a/packages/webapp/src/components/Form/InFormButtons/index.tsx b/packages/webapp/src/components/Form/InFormButtons/index.tsx
new file mode 100644
index 0000000000..91cfd69894
--- /dev/null
+++ b/packages/webapp/src/components/Form/InFormButtons/index.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import clsx from 'clsx';
+import { useTranslation } from 'react-i18next';
+import Button from '../Button';
+import { Info } from '../../Typography';
+import styles from './styles.module.scss';
+
+export interface InFormButtonsProps {
+ isDisabled?: boolean;
+ statusText?: string;
+ confirmText: string;
+ informationalText?: string;
+ onCancel: () => void;
+ onConfirm?: () => void;
+ confirmButtonType?: 'button' | 'submit';
+ className?: string;
+}
+
+const InFormButtons = ({
+ isDisabled,
+ statusText,
+ confirmText,
+ informationalText,
+ onCancel,
+ onConfirm,
+ confirmButtonType = 'button',
+ className,
+}: InFormButtonsProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {statusText && {statusText}}
+
+
+
+ {informationalText && {informationalText}}
+
+ );
+};
+
+export default InFormButtons;
diff --git a/packages/webapp/src/components/Form/InFormButtons/styles.module.scss b/packages/webapp/src/components/Form/InFormButtons/styles.module.scss
new file mode 100644
index 0000000000..ad9ac4fe0a
--- /dev/null
+++ b/packages/webapp/src/components/Form/InFormButtons/styles.module.scss
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+@import '../../../assets/mixin';
+
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+}
+
+.statusText {
+ font-size: 16px;
+ color: var(--Colors-Neutral-Neutral-300);
+
+ @include xs-breakpoint {
+ display: none;
+ }
+}
+
+.informationalText {
+ margin-top: 0;
+ text-align: center;
+ color: var(--Colors-Neutral-Neutral-300);
+}
+
+.buttons {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+}
+
+.button {
+ min-height: 32px;
+ height: 32px;
+ font-size: 14px;
+ display: inline-block;
+
+ @include truncateText();
+}
+
+.confirmButton {
+ color: var(--Colors-Neutral-Neutral-900);
+ border: 1px solid var(--Colors-Neutral-Neutral-900);
+
+ &:disabled {
+ color: var(--Colors-Neutral-Neutral-100);
+ border: 1px solid var(--Colors-Neutral-Neutral-100);
+ }
+}
diff --git a/packages/webapp/src/components/Form/InputBase/InputBaseField/index.tsx b/packages/webapp/src/components/Form/InputBase/InputBaseField/index.tsx
new file mode 100644
index 0000000000..fc762e387c
--- /dev/null
+++ b/packages/webapp/src/components/Form/InputBase/InputBaseField/index.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import { ReactElement, ReactNode, forwardRef } from 'react';
+import styles from './styles.module.scss';
+import clsx from 'clsx';
+import { HTMLInputProps } from '..';
+
+export type InputBaseFieldProps = {
+ leftSection?: ReactNode;
+ mainSection?: ReactNode;
+ rightSection?: ReactNode;
+ isError?: boolean;
+ resetIcon?: ReactElement;
+ resetIconPosition?: 'left' | 'right';
+} & HTMLInputProps;
+
+const InputBaseField = forwardRef((props, ref) => {
+ const {
+ isError,
+ resetIcon,
+ resetIconPosition = 'right',
+ leftSection,
+ mainSection,
+ rightSection,
+ ...inputProps
+ } = props;
+
+ return (
+
+ );
+});
+
+export default InputBaseField;
diff --git a/packages/webapp/src/components/Form/InputBase/InputBaseField/styles.module.scss b/packages/webapp/src/components/Form/InputBase/InputBaseField/styles.module.scss
new file mode 100644
index 0000000000..745510dfd6
--- /dev/null
+++ b/packages/webapp/src/components/Form/InputBase/InputBaseField/styles.module.scss
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+.inputWrapper .input {
+ all: unset;
+ height: 100%;
+ width: 100%;
+ flex: 1;
+}
+
+.inputWrapper {
+ display: flex;
+ cursor: text;
+ width: 100%;
+ border: 1px solid var(--grey400);
+ box-sizing: border-box;
+ border-radius: 4px;
+ height: 48px;
+ padding: 0 8px;
+ font-size: 16px;
+ line-height: 24px;
+ color: var(--fontColor);
+ background-color: white;
+ font-family: 'Open Sans', 'SansSerif', serif;
+}
+
+.inputDisabled {
+ background-color: var(--inputDisabled) !important;
+ color: var(--grey600);
+ border-color: var(--inputDefault);
+ cursor: not-allowed;
+
+ > * {
+ pointer-events: none;
+ }
+}
+
+.inputWrapper:focus-within {
+ border-color: var(--inputActive);
+}
+
+.inputWrapper ::placeholder {
+ color: var(--grey500);
+}
+
+.inputWrapper .input:focus::placeholder {
+ color: transparent;
+}
+
+.inputError {
+ border-color: var(--error);
+}
+
+.inputSection {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ cursor: default;
+
+ &Left {
+ padding-right: 6px;
+ }
+ &Right {
+ padding-left: 6px;
+
+ &.resetIconLeft {
+ flex-direction: row-reverse;
+ }
+ }
+}
diff --git a/packages/webapp/src/components/Form/InputBase/InputBaseLabel/index.tsx b/packages/webapp/src/components/Form/InputBase/InputBaseLabel/index.tsx
new file mode 100644
index 0000000000..47895f06a9
--- /dev/null
+++ b/packages/webapp/src/components/Form/InputBase/InputBaseLabel/index.tsx
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import Infoi from '../../../Tooltip/Infoi';
+import { ReactComponent as Leaf } from '../../../../assets/images/signUp/leaf.svg';
+import { Label } from '../../../Typography';
+import styles from './styles.module.scss';
+import { useTranslation } from 'react-i18next';
+import { ReactNode } from 'react';
+
+export type InputBaseLabelProps = {
+ label?: string;
+ optional?: boolean;
+ hasLeaf?: boolean;
+ toolTipContent?: string;
+ icon?: ReactNode;
+ labelStyles?: React.CSSProperties;
+};
+
+export default function InputBaseLabel(props: InputBaseLabelProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {props.toolTipContent && (
+
+
+
+ )}
+ {props.icon && {props.icon}}
+
+ );
+}
diff --git a/packages/webapp/src/components/Form/ReactSelect/reactSelect.module.scss b/packages/webapp/src/components/Form/InputBase/InputBaseLabel/styles.module.scss
similarity index 81%
rename from packages/webapp/src/components/Form/ReactSelect/reactSelect.module.scss
rename to packages/webapp/src/components/Form/InputBase/InputBaseLabel/styles.module.scss
index 8a46f74ee6..fbc3bb2c7b 100644
--- a/packages/webapp/src/components/Form/ReactSelect/reactSelect.module.scss
+++ b/packages/webapp/src/components/Form/InputBase/InputBaseLabel/styles.module.scss
@@ -1,5 +1,5 @@
/*
- * Copyright 2019, 2020, 2021, 2022, 2023 LiteFarm.org
+ * Copyright 2024 LiteFarm.org
* This file is part of LiteFarm.
*
* LiteFarm is free software: you can redistribute it and/or modify
@@ -13,18 +13,6 @@
* GNU General Public License for more details, see .
*/
-.sm {
- margin-left: 8px;
-}
-
-.container {
- display: flex;
- flex-direction: column;
- overflow: visible;
- position: relative;
- min-width: 0;
-}
-
.labelContainer {
display: flex;
justify-content: space-between;
@@ -32,9 +20,8 @@
position: relative;
}
-.labelText {
- position: absolute;
- bottom: 0;
+.sm {
+ margin-left: 8px;
}
.leaf {
@@ -51,6 +38,7 @@
.icon {
position: absolute;
right: 0;
+ top: -8px;
color: var(--iconDefault);
}
diff --git a/packages/webapp/src/components/Form/InputBase/index.tsx b/packages/webapp/src/components/Form/InputBase/index.tsx
new file mode 100644
index 0000000000..b24521d342
--- /dev/null
+++ b/packages/webapp/src/components/Form/InputBase/index.tsx
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import styles from './styles.module.scss';
+import { ComponentPropsWithoutRef, forwardRef } from 'react';
+import InputBaseLabel from './InputBaseLabel';
+import InputBaseField from './InputBaseField';
+import { Error, Info, TextWithExternalLink } from '../../Typography';
+import { Cross } from '../../Icons';
+import type { InputBaseFieldProps } from './InputBaseField';
+import type { InputBaseLabelProps } from './InputBaseLabel';
+import clsx from 'clsx';
+
+export type HTMLInputProps = ComponentPropsWithoutRef<'input'>;
+
+// props meant to be shared with other similar input components
+export type InputBaseSharedProps = InputBaseLabelProps & {
+ showResetIcon?: boolean;
+ showErrorText?: boolean;
+ onResetIconClick?: () => void;
+ info?: string;
+ error?: string;
+ link?: string;
+ textWithExternalLink?: string;
+ classes?: Partial<
+ Record<'input' | 'label' | 'container' | 'info' | 'errors', React.CSSProperties>
+ >;
+} & Pick;
+
+type InputBaseProps = InputBaseSharedProps &
+ Pick &
+ HTMLInputProps;
+
+const InputBase = forwardRef((props, ref) => {
+ const {
+ label,
+ optional,
+ hasLeaf,
+ toolTipContent,
+ error,
+ info,
+ textWithExternalLink,
+ link,
+ icon,
+ leftSection,
+ mainSection,
+ rightSection,
+ showResetIcon = true,
+ showErrorText = true,
+ onResetIconClick,
+ classes,
+ className,
+ ...inputProps
+ } = props;
+
+ return (
+
+ );
+});
+
+export default InputBase;
diff --git a/packages/webapp/src/components/Form/InputBase/styles.module.scss b/packages/webapp/src/components/Form/InputBase/styles.module.scss
new file mode 100644
index 0000000000..a95da5ed57
--- /dev/null
+++ b/packages/webapp/src/components/Form/InputBase/styles.module.scss
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+.inputWrapper {
+ position: relative;
+}
+
+.inputWrapper input:disabled + .stepper .stepperIcons {
+ pointer-events: none;
+}
diff --git a/packages/webapp/src/components/Form/NumberInput/NumberInputStepper.tsx b/packages/webapp/src/components/Form/NumberInput/NumberInputStepper.tsx
new file mode 100644
index 0000000000..7ebf8d2d34
--- /dev/null
+++ b/packages/webapp/src/components/Form/NumberInput/NumberInputStepper.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import styles from './stepper.module.scss';
+import { ReactComponent as IncrementIcon } from '../../../assets/images/number-input-increment.svg';
+import { ReactComponent as DecrementIcon } from '../../../assets/images/number-input-decrement.svg';
+import { ComponentPropsWithoutRef, PropsWithChildren } from 'react';
+import clsx from 'clsx';
+
+export type NumberInputStepperProps = {
+ increment: () => void;
+ decrement: () => void;
+ incrementDisabled: boolean;
+ decrementDisabled: boolean;
+};
+
+export default function NumberInputStepper(props: NumberInputStepperProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function NumberInputStepperButton(
+ props: PropsWithChildren>,
+) {
+ return (
+
+ );
+}
diff --git a/packages/webapp/src/components/Form/NumberInput/index.tsx b/packages/webapp/src/components/Form/NumberInput/index.tsx
new file mode 100644
index 0000000000..3e2333d2e0
--- /dev/null
+++ b/packages/webapp/src/components/Form/NumberInput/index.tsx
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import { ReactNode } from 'react';
+import InputBase, { type InputBaseSharedProps } from '../InputBase';
+import NumberInputStepper from './NumberInputStepper';
+import useNumberInput, { NumberInputOptions } from './useNumberInput';
+import { FieldValues, UseControllerProps, useController, get } from 'react-hook-form';
+
+export type NumberInputProps = UseControllerProps &
+ InputBaseSharedProps &
+ Omit & {
+ /**
+ * The currency symbol to display on left side of input
+ */
+ currencySymbol?: ReactNode;
+ /**
+ * The unit to display on right side of input
+ */
+ unit?: ReactNode;
+ /**
+ * Controls visibility of stepper.
+ */
+ showStepper?: boolean;
+
+ className?: string;
+ };
+
+export default function NumberInput({
+ locale,
+ useGrouping = true,
+ allowDecimal = true,
+ decimalDigits,
+ unit,
+ currencySymbol,
+ step = 1,
+ max = Infinity,
+ min = 0,
+ showStepper = false,
+ clampOnBlur = true,
+ name,
+ control,
+ rules,
+ defaultValue,
+ className,
+ onChange,
+ onBlur,
+ ...props
+}: NumberInputProps) {
+ const { field, fieldState, formState } = useController({ name, control, rules, defaultValue });
+ const { inputProps, reset, numericValue, increment, decrement } = useNumberInput({
+ initialValue: get(formState.defaultValues, name) || defaultValue,
+ allowDecimal,
+ decimalDigits,
+ locale,
+ useGrouping,
+ step,
+ min,
+ max,
+ clampOnBlur,
+ onChange: (value) => {
+ field.onChange(isNaN(value) ? null : value);
+ onChange?.(value);
+ },
+ onBlur: () => {
+ field.onBlur();
+ onBlur?.();
+ },
+ });
+
+ return (
+
+ {unit}
+ {showStepper && (
+
+ )}
+ >
+ }
+ />
+ );
+}
diff --git a/packages/webapp/src/components/Form/NumberInput/stepper.module.scss b/packages/webapp/src/components/Form/NumberInput/stepper.module.scss
new file mode 100644
index 0000000000..a5ae4d81ef
--- /dev/null
+++ b/packages/webapp/src/components/Form/NumberInput/stepper.module.scss
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+.stepper {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ height: 24px;
+ width: 24px;
+}
+
+.stepperBtnUnstyled {
+ all: unset;
+ cursor: pointer;
+
+ &:disabled {
+ cursor: not-allowed;
+ }
+}
+
+.stepperBtn {
+ background-color: var(--Colors-Neutral-Neutral-50);
+ color: var(--Colors-Neutral-Neutral-600);
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ &:first-child {
+ border-top-right-radius: 3px;
+ }
+ &:last-child {
+ border-bottom-right-radius: 3px;
+ }
+
+ &:disabled {
+ background-color: #f9fafc;
+ color: #dadee5;
+ }
+}
diff --git a/packages/webapp/src/components/Form/NumberInput/useNumberInput.ts b/packages/webapp/src/components/Form/NumberInput/useNumberInput.ts
new file mode 100644
index 0000000000..db9b31faac
--- /dev/null
+++ b/packages/webapp/src/components/Form/NumberInput/useNumberInput.ts
@@ -0,0 +1,208 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import { useTranslation } from 'react-i18next';
+import { clamp, countDecimalPlaces, createNumberFormatter } from './utils';
+import { ChangeEvent, ComponentPropsWithRef, useMemo, useRef, useState } from 'react';
+
+export type NumberInputOptions = {
+ /**
+ * Value shown on first render.
+ */
+ initialValue?: number | null;
+ /**
+ * Controls grouping of numbers over 1000 with the thousands separator.
+ */
+ useGrouping?: boolean;
+ /**
+ * Controls whether or not a decimal is allowed as input. If set to false, users can only enter whole numbers.
+ */
+ allowDecimal?: boolean;
+ /**
+ * The locale to use for number formatting.
+ */
+ locale?: string;
+ /**
+ * Number of decimal digits shown after blur.
+ */
+ decimalDigits?: number;
+ /**
+ * - Amount to increment or decrement.
+ * - If allowDecimal is false, then step is rounded to the nearest whole number.
+ */
+ step?: number;
+ /**
+ * - Maximum value of input.
+ * - If input value is greater than max then input value is clamped to max on blur.
+ * - If input value equals max then incrementing with stepper and keyboard is disabled.
+ */
+ max?: number;
+ /**
+ * - Minimum value of input.
+ * - If input value is less than min then input value is clamped to min on blur.
+ * - If input value equals min then decrementing with stepper and keyboard is disabled.
+ */
+ min?: number;
+ /**
+ * Controls whether or not to clamp value on blur that is outside of allowed range
+ */
+ clampOnBlur?: boolean;
+ /**
+ * Function called when number value of input changes.
+ * @param value - Current value represented as number or NaN if input field is empty.
+ */
+ onChange?: (value: number) => void;
+ /**
+ * Function called when input is blurred.
+ */
+ onBlur?: () => void;
+};
+
+export default function useNumberInput({
+ initialValue,
+ locale: customLocale,
+ decimalDigits,
+ allowDecimal = true,
+ useGrouping = true,
+ step = 1,
+ min = 0,
+ max = Infinity,
+ clampOnBlur = true,
+ onChange,
+ onBlur,
+}: NumberInputOptions) {
+ const {
+ i18n: { language },
+ } = useTranslation();
+
+ const locale = customLocale || language;
+
+ const formatter = useMemo(() => {
+ const stepDecimalPlaces = countDecimalPlaces(step);
+ const options: Intl.NumberFormatOptions = {
+ useGrouping,
+ minimumFractionDigits: !allowDecimal ? undefined : decimalDigits ?? stepDecimalPlaces,
+ maximumFractionDigits: !allowDecimal ? 0 : decimalDigits ?? (stepDecimalPlaces || 20),
+ };
+
+ return createNumberFormatter(locale, options);
+ }, [locale, useGrouping, decimalDigits, step, allowDecimal]);
+
+ const { decimalSeparator, thousandsSeparator } = useMemo(() => {
+ let separators = {
+ decimalSeparator: '.',
+ thousandsSeparator: ',',
+ };
+
+ // 11000.2 - random decimal number over 1000 used to extract thousands and decimal separator
+ const numberParts = createNumberFormatter(locale).formatToParts(11000.2);
+ for (let { type, value } of numberParts) {
+ if (type === 'decimal') {
+ separators.decimalSeparator = value;
+ } else if (type === 'group') {
+ separators.thousandsSeparator = value;
+ }
+ }
+ return separators;
+ }, [locale]);
+
+ const [numericValue, setNumericValue] = useState(initialValue ?? NaN);
+
+ // current input value that is focused and has been touched
+ const [touchedValue, setTouchedValue] = useState('');
+ const [isFocused, setIsFocused] = useState(false);
+ const inputRef = useRef(null);
+ const stepValue = allowDecimal ? step : Math.round(step);
+
+ const update = (next: number) => {
+ setNumericValue(next);
+ onChange?.(next);
+ };
+
+ const handleChange = (e: ChangeEvent) => {
+ const { value, validity } = e.target;
+ if (validity.patternMismatch) return;
+ setTouchedValue(value);
+ update(parseFloat(decimalSeparator === '.' ? value : value.replace(decimalSeparator, '.')));
+ };
+
+ const handleBlur = () => {
+ if (clampOnBlur && (numericValue < min || numericValue > max)) {
+ update(clamp(numericValue, min, max));
+ }
+ setIsFocused(false);
+ setTouchedValue('');
+ onBlur?.();
+ };
+
+ const pattern = useMemo(() => {
+ if (!isFocused) return;
+ if (!allowDecimal) return '[0-9]+';
+ const decimalSeparatorRegex = `[${decimalSeparator === '.' ? '.' : `${decimalSeparator}.`}]`;
+ return `[0-9]*${decimalSeparatorRegex}?[0-9]*`;
+ }, [isFocused, allowDecimal, decimalSeparator]);
+
+ const getDisplayValue = () => {
+ if (isNaN(numericValue)) return '';
+ if (isFocused)
+ return (
+ touchedValue ||
+ (numericValue && formatter.format(numericValue).replaceAll(thousandsSeparator, '')) ||
+ ''
+ );
+ return formatter.format(numericValue);
+ };
+
+ const handleStep = (next: number) => {
+ // focus input when clicking on up/down button
+ if (!isFocused) inputRef.current?.focus();
+ if (touchedValue) setTouchedValue('');
+ update(clamp(next, Math.max(min, 0), max));
+ };
+
+ const increment = () => handleStep((numericValue || 0) + stepValue);
+ const decrement = () => handleStep((numericValue || 0) - stepValue);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowUp') {
+ // prevent cursor from shifting to start of input
+ e.preventDefault();
+ increment();
+ } else if (e.key === 'ArrowDown') {
+ decrement();
+ }
+ };
+
+ const inputProps: ComponentPropsWithRef<'input'> = {
+ inputMode: 'decimal',
+ value: getDisplayValue(),
+ pattern,
+ onChange: handleChange,
+ onBlur: handleBlur,
+ onFocus: () => setIsFocused(true),
+ onKeyDown: handleKeyDown,
+ ref: inputRef,
+ };
+
+ return {
+ numericValue,
+ inputProps,
+ reset: () => update(initialValue ?? NaN),
+ clear: () => update(NaN),
+ update,
+ increment,
+ decrement,
+ };
+}
diff --git a/packages/webapp/src/components/Form/NumberInput/utils.ts b/packages/webapp/src/components/Form/NumberInput/utils.ts
new file mode 100644
index 0000000000..138ea18efc
--- /dev/null
+++ b/packages/webapp/src/components/Form/NumberInput/utils.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+export function countDecimalPlaces(number: number) {
+ if (!Number.isFinite(number)) return 0;
+ let e = 1;
+ let decimalPlaces = 0;
+ while (Math.round(number * e) / e !== number) {
+ e *= 10;
+ decimalPlaces += 1;
+ }
+ return decimalPlaces;
+}
+
+export function clamp(value: number, min: number, max: number) {
+ if (max < min) console.warn('clamp: max cannot be less than min');
+
+ return Math.min(Math.max(value, min), max);
+}
+
+export function createNumberFormatter(locale: string, options?: Intl.NumberFormatOptions) {
+ try {
+ return new Intl.NumberFormat(locale, options);
+ } catch (error) {
+ // undefined will use browsers best matching locale
+ return new Intl.NumberFormat(undefined, options);
+ }
+}
diff --git a/packages/webapp/src/components/Form/ReactSelect/CreatableSelect.tsx b/packages/webapp/src/components/Form/ReactSelect/CreatableSelect.tsx
new file mode 100644
index 0000000000..bb3871b351
--- /dev/null
+++ b/packages/webapp/src/components/Form/ReactSelect/CreatableSelect.tsx
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2024 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import React from 'react';
+import Select, { CreatableProps } from 'react-select/creatable';
+import { GroupBase, SelectInstance } from 'react-select';
+import { styles as baseStyles } from './styles';
+import InputBaseLabel, { InputBaseLabelProps } from '../InputBase/InputBaseLabel';
+import { useTranslation } from 'react-i18next';
+import { ClearIndicator, MultiValueRemove, MenuOpenDropdownIndicator } from './components';
+import scss from './styles.module.scss';
+
+type CreatableSelectProps<
+ Option = unknown,
+ IsMulti extends boolean = false,
+ Group extends GroupBase