diff --git a/lerna.json b/lerna.json index d8b5fb59..aacb290f 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "packages": ["packages/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.10.2", + "version": "0.11.0", "command": { "version": { "message": "chore(release): publish %s" diff --git a/packages/apsara-icons/package.json b/packages/apsara-icons/package.json index fd5d4dc7..238ccf35 100644 --- a/packages/apsara-icons/package.json +++ b/packages/apsara-icons/package.json @@ -1,6 +1,6 @@ { "name": "@goto-company/icons", - "version": "0.10.2", + "version": "0.11.0", "description": "Apsara icons", "scripts": { "build": "node scripts/build.js", diff --git a/packages/apsara-ui/package.json b/packages/apsara-ui/package.json index 7442fd3c..a646c9f4 100644 --- a/packages/apsara-ui/package.json +++ b/packages/apsara-ui/package.json @@ -1,6 +1,6 @@ { "name": "@goto-company/apsara", - "version": "0.10.2", + "version": "0.11.0", "description": "A list of base components for apsara", "author": "Praveen Yadav ", "license": "Apache-2.0", @@ -32,7 +32,8 @@ "homepage": "https://goto.github.io/apsara/", "peerDependencies": { "react": "^16.13.1", - "react-dom": "^16.13.1" + "react-dom": "^16.13.1", + "react-hook-form": "7.43.1" }, "dependencies": { "@ant-design/icons": "^4.2.2", @@ -75,7 +76,9 @@ "react-window": "^1.8.5", "react-window-infinite-loader": "^1.0.5", "styled-components": "^5.2.0", - "styled-system": "^5.1.5" + "styled-system": "^5.1.5", + "@hookform/error-message": "2.0.1", + "react-hook-form": "7.43.1" }, "devDependencies": { "@rollup/plugin-json": "^4.1.0", diff --git a/packages/apsara-ui/src/Checkbox/Checkbox.tsx b/packages/apsara-ui/src/Checkbox/Checkbox.tsx index 56381aba..aa049171 100644 --- a/packages/apsara-ui/src/Checkbox/Checkbox.tsx +++ b/packages/apsara-ui/src/Checkbox/Checkbox.tsx @@ -2,6 +2,7 @@ import { CheckIcon } from "@radix-ui/react-icons"; import React, { useEffect, useState } from "react"; import { CheckboxWrapper, CheckboxGroupWrapper, StyledCheckbox, StyledIndicator } from "./Checkbox.styles"; import { generateRandomId } from "../helper"; +import transformCheckedValue from "../helper/transform-checked-value"; type CheckboxProps = { defaultChecked?: boolean; @@ -29,6 +30,7 @@ type CheckboxGroupProps = { id?: string; }; +const prefixCls = "apsara-checkbox"; const Checkbox = ({ defaultChecked = false, checked, @@ -48,11 +50,11 @@ const Checkbox = ({ }, [checked]); return ( - + @@ -95,7 +98,7 @@ const CheckboxGroup = ({ }; return ( - + {options && options.map((option, index) => (
diff --git a/packages/apsara-ui/src/Combobox/Combobox.tsx b/packages/apsara-ui/src/Combobox/Combobox.tsx index c2138f57..c237a4c9 100644 --- a/packages/apsara-ui/src/Combobox/Combobox.tsx +++ b/packages/apsara-ui/src/Combobox/Combobox.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { SelectProps, OptGroup, Option } from "rc-select"; +import React, { forwardRef } from "react"; +import Select, { SelectProps, OptGroup, Option } from "rc-select"; import { NotFoundContent, StyledMultiSelect } from "./Combobox.styles"; import { useState } from "react"; import Icon from "../Icon"; @@ -20,22 +20,23 @@ const loadingContent = ( ); -const Combobox = ({ - options, - mode, - value, - onChange, - onSearch, - onSelect, - onDeselect, - allowClear = true, - showSearch = true, - showArrow = true, - filterOption = true, - placeholder, - optionFilterProp, - ...props -}: SelectProps) => { +const Combobox = forwardRef, SelectProps>((props, ref) => { + const { + options, + mode, + value, + onChange, + onSearch, + onSelect, + onDeselect, + allowClear = true, + showSearch = true, + showArrow = true, + filterOption = true, + placeholder, + optionFilterProp, + ...restProps + } = props; const [showInputIcon, setShowInputIcon] = useState(true); const [isValue, setIsValue] = useState(false); const [inputIcon, setInputIcon] = useState(ArrowIcon); @@ -73,8 +74,8 @@ const Combobox = ({ return ( - {props.children} + {restProps.children} ); -}; +}); -Combobox.Option = Option; -Combobox.OptGroup = OptGroup; +Combobox.displayName = "Combobox"; -export default Combobox; +const CompoundCombobox = Object.assign(Combobox, { Option, OptGroup }); + +export default CompoundCombobox; diff --git a/packages/apsara-ui/src/DatePicker/DatePicker.tsx b/packages/apsara-ui/src/DatePicker/DatePicker.tsx index b25040bc..221247eb 100644 --- a/packages/apsara-ui/src/DatePicker/DatePicker.tsx +++ b/packages/apsara-ui/src/DatePicker/DatePicker.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React, { forwardRef, useRef } from "react"; import Picker from "rc-picker"; import enUS from "rc-picker/lib/locale/en_US"; import { CalendarOutlined, ClockCircleOutlined, CloseCircleFilled } from "@ant-design/icons/"; @@ -22,14 +22,15 @@ type DatePickerProps = { }; const prefixCls = "apsara-picker"; -const DatePicker = ({ - className, - picker = "date", - placeholder = picker == "date" ? "Select date" : "Select time", - generateConfig = momentGenerateConfig, - width = "100%", - ...props -}: DatePickerProps) => { +const DatePicker = forwardRef((props, ref) => { + const { + className, + picker = "date", + placeholder = picker == "date" ? "Select date" : "Select time", + generateConfig = momentGenerateConfig, + width = "100%", + ...restProps + } = props; const datePickerDropdownWrapperRef = useRef(null); return ( @@ -40,7 +41,7 @@ const DatePicker = ({ suffixIcon={picker === "time" ? : } clearIcon={} allowClear - {...props} + {...restProps} className={className} prefixCls={prefixCls} locale={enUS} @@ -52,11 +53,14 @@ const DatePicker = ({ nextIcon={} superPrevIcon={} superNextIcon={} + ref={ref} /> ); -}; +}); + +DatePicker.displayName = "DatePicker"; export default DatePicker; diff --git a/packages/apsara-ui/src/Input/Input.styles.tsx b/packages/apsara-ui/src/Input/Input.styles.tsx index c6668ca2..7641b5f2 100644 --- a/packages/apsara-ui/src/Input/Input.styles.tsx +++ b/packages/apsara-ui/src/Input/Input.styles.tsx @@ -116,6 +116,7 @@ const StyledWrapper = styled("div")<{ `; export const TextAreaWrapper = styled("div")<{ size?: "small" | "middle" | "large" }>` + display: flex; .input_textarea_main { width: 100%; border: 1px solid ${({ theme }) => theme?.input?.border}; diff --git a/packages/apsara-ui/src/Input/Input.tsx b/packages/apsara-ui/src/Input/Input.tsx index a6242b9d..a495ba0d 100644 --- a/packages/apsara-ui/src/Input/Input.tsx +++ b/packages/apsara-ui/src/Input/Input.tsx @@ -1,5 +1,6 @@ -import React, { useState } from "react"; +import React, { forwardRef, useImperativeHandle, useRef } from "react"; import StyledWrapper, { TextAreaWrapper } from "./Input.styles"; +import { PREFIX_CLS } from "./constants"; export type InputProps = { size?: "small" | "middle" | "large"; @@ -12,44 +13,51 @@ type TextAreaProps = { size?: "small" | "middle" | "large"; } & Omit, "size">; -const Input = ({ - suffix, - prefix, - placeholder = "", - allowClear = false, - size = "middle", - onChange, - type, - value, - ...props -}: InputProps) => { - const [ref, setRef] = useState(null); - const renderClearButton = value && allowClear && !props.disabled; +const Input = forwardRef((props, ref) => { + const { + suffix, + prefix, + placeholder = "", + allowClear = false, + size = "middle", + onChange, + type, + value, + ...restProps + } = props; + const localRef = useRef(null); + const renderClearButton = value && allowClear && !restProps.disabled; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + useImperativeHandle(ref, () => localRef.current!, []); const onValueChange = (e: any) => { onChange && onChange(e); }; return ( - + {prefix != "" && prefix != null && {prefix}}
{ - setRef(input); - }} + ref={localRef} onChange={onValueChange} value={value} type={type ? type : "text"} placeholder={placeholder} - {...props} + {...restProps} className="input_main" /> {renderClearButton && ( { onValueChange({ target: { value: "" } }); - ref?.focus(); + localRef.current?.focus(); }} className="input_close_icon" > @@ -58,21 +66,25 @@ const Input = ({ )}
{suffix != "" && suffix != null && {suffix}} - {props.children} + {restProps.children}
); -}; +}); -const TextArea = ({ size = "middle", ...props }: TextAreaProps) => { +const TextArea = forwardRef((props, ref) => { + const { size = "middle", ...restProps } = props; return ( - ); -}; +}); + +TextArea.displayName = "TextArea"; +Input.displayName = "Input"; -Input.TextArea = TextArea; +const CompoundInput = Object.assign(Input, { TextArea }); -export default Input; +export default CompoundInput; diff --git a/packages/apsara-ui/src/Input/constants/index.ts b/packages/apsara-ui/src/Input/constants/index.ts new file mode 100644 index 00000000..cdea88d0 --- /dev/null +++ b/packages/apsara-ui/src/Input/constants/index.ts @@ -0,0 +1 @@ +export const PREFIX_CLS = "apsara-input"; diff --git a/packages/apsara-ui/src/Radio/Radio.tsx b/packages/apsara-ui/src/Radio/Radio.tsx index 84347c5d..2c2961f6 100644 --- a/packages/apsara-ui/src/Radio/Radio.tsx +++ b/packages/apsara-ui/src/Radio/Radio.tsx @@ -1,9 +1,10 @@ -import React from "react"; +import React, { forwardRef } from "react"; import { RadioGroup, StyledRadioItem, StyledIndicator, Label, Flex, StyledRadioButton } from "./Radio.styles"; import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import { generateRandomId } from "../helper"; +import { PREFIX_CLS } from "./constants"; -type RadioItem = { +export type RadioItem = { label?: string; value: string; disabled?: boolean; @@ -39,50 +40,44 @@ export type RadioProps = { id?: string; }; -const Radio = ({ - defaultValue, - value, - items, - onChange, - required, - orientation, - dir, - id = generateRandomId(), - ...props -}: RadioProps) => { - return ( - - {items && - items.map((item, i) => ( - - - - - - - ))} - {!items && props.children} - - ); -}; +const Radio = forwardRef( + ({ defaultValue, value, items, onChange, required, orientation, dir, id = generateRandomId(), ...props }, ref) => { + return ( + + {items && + items.map((item, i) => ( + + + + + + + ))} + {!items && props.children} + + ); + }, +); const RadioButton = ({ label, value, children, ...props }: RadioButtonType) => { return ( @@ -95,9 +90,13 @@ const RadioButton = ({ label, value, children, ...props }: RadioButtonType) => { ); }; -Radio.Root = RadioGroup; -Radio.Item = StyledRadioItem; -Radio.Indicator = StyledIndicator; -Radio.Button = RadioButton; +Radio.displayName = "Radio"; + +const CompoundRadio = Object.assign(Radio, { + Root: RadioGroup, + Item: StyledRadioItem, + Indicator: StyledIndicator, + Button: RadioButton, +}); -export default Radio; +export default CompoundRadio; diff --git a/packages/apsara-ui/src/Radio/constants/index.ts b/packages/apsara-ui/src/Radio/constants/index.ts new file mode 100644 index 00000000..fab46bb9 --- /dev/null +++ b/packages/apsara-ui/src/Radio/constants/index.ts @@ -0,0 +1 @@ +export const PREFIX_CLS = "apsara-radio"; diff --git a/packages/apsara-ui/src/Select/Select.tsx b/packages/apsara-ui/src/Select/Select.tsx index c657a8c6..903b303b 100644 --- a/packages/apsara-ui/src/Select/Select.tsx +++ b/packages/apsara-ui/src/Select/Select.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { forwardRef, useEffect, useState } from "react"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; import { SelectRoot, @@ -16,6 +16,7 @@ import { SelectScrollUpButton, SelectScrollDownButton, } from "./Select.styles"; +import { PREFIX_CLS } from "./constants"; type Item = { value: string; @@ -49,17 +50,18 @@ export type SelectProps = { itemProps?: StyleProps; }; -const Select = ({ - defaultValue = "", - value, - name, - onChange, - groups, - defaultOpen = false, - open, - onOpenChange, - ...props -}: SelectProps) => { +const Select = forwardRef((props, ref) => { + const { + defaultValue = "", + value, + name, + onChange, + groups, + defaultOpen = false, + open, + onOpenChange, + ...restProps + } = props; const lastInd = groups.length - 1; const [showDefaultItem, setShowDefaultItem] = useState(true); @@ -87,14 +89,20 @@ const Select = ({ open={open} onOpenChange={onOpenChange} > - + - - + + @@ -116,7 +124,7 @@ const Select = ({ key={item.value} value={item.value} disabled={item.disabled} - {...props.itemProps} + {...restProps.itemProps} > {item.displayText} @@ -126,16 +134,18 @@ const Select = ({ ))} - {i != lastInd && } + {i != lastInd && }
))} - + ); -}; +}); + +Select.displayName = "Select"; export default Select; diff --git a/packages/apsara-ui/src/Select/constants/index.ts b/packages/apsara-ui/src/Select/constants/index.ts new file mode 100644 index 00000000..358233c9 --- /dev/null +++ b/packages/apsara-ui/src/Select/constants/index.ts @@ -0,0 +1 @@ +export const PREFIX_CLS = "apsara-select"; diff --git a/packages/apsara-ui/src/Switch/Switch.tsx b/packages/apsara-ui/src/Switch/Switch.tsx index f1a12ead..fbe90298 100644 --- a/packages/apsara-ui/src/Switch/Switch.tsx +++ b/packages/apsara-ui/src/Switch/Switch.tsx @@ -1,5 +1,6 @@ -import React from "react"; +import React, { forwardRef } from "react"; import { StyledSwitch, StyledThumb } from "./Switch.styles"; +import transformCheckedValue from "../helper/transform-checked-value"; type StyleProps = { className?: string; @@ -21,23 +22,25 @@ export type SwitchProps = { thumbProps?: StyleProps; }; -const Switch = ({ - defaultChecked = false, - checked, - onChange, - disabled = false, - required, - name, - value, - color, - id, - ...props -}: SwitchProps) => { +const Switch = forwardRef((props, ref) => { + const { + defaultChecked = false, + checked, + onChange, + disabled = false, + required, + name, + value, + color, + id, + ...restProps + } = props; + return ( - + ); -}; +}); + +Switch.displayName = "Switch"; export default Switch; diff --git a/packages/apsara-ui/src/form-builder-v2/components/field/field.styles.ts b/packages/apsara-ui/src/form-builder-v2/components/field/field.styles.ts new file mode 100644 index 00000000..ce1a0fe1 --- /dev/null +++ b/packages/apsara-ui/src/form-builder-v2/components/field/field.styles.ts @@ -0,0 +1,55 @@ +import styled from "styled-components"; + +const errorColor = "#ef4444"; + +const shakeAnimation = ` + @keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 20%, + 60% { + transform: translateX(-4px); + } + 40%, + 80% { + transform: translateX(4px); + } + } +`; + +export const FieldWrapper = styled("div")<{ error?: boolean }>` + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 20px; + .apsara-form-builder-v2-label-wrapper { + display: flex; + gap: 4px; + align-items: center; + } + .apsara-form-builder-v2-label { + font-size: 12px; + text-transform: uppercase; + } + ${({ error }) => + error + ? ` + .apsara-input, .apsara-input:focus, .apsara-input:hover, .input_textarea_main, .input_textarea_main:focus, .input_textarea_main:hover, .rc-select-selector, .rc-select-selector:hover, .apsara-picker, .apsara-picker:hover, .apsara-input-number, .apsara-select-trigger, .apsara-radio:not(:disabled), .apsara-checkbox { + border: 1px solid ${errorColor}; + } + ` + : ""} +`; + +export const ErrorMessage = styled("div")` + color: ${errorColor}; + &::first-letter { + text-transform: capitalize; + } + &.shake { + animation: shake 0.4s ease-in-out; + } + ${shakeAnimation} +`; diff --git a/packages/apsara-ui/src/form-builder-v2/components/field/field.tsx b/packages/apsara-ui/src/form-builder-v2/components/field/field.tsx new file mode 100644 index 00000000..80128fb2 --- /dev/null +++ b/packages/apsara-ui/src/form-builder-v2/components/field/field.tsx @@ -0,0 +1,92 @@ +import React, { memo, ReactElement, ReactNode } from "react"; +import { useFormContext, useFormState, Controller, UseControllerProps } from "react-hook-form"; +import { ErrorMessage, FieldWrapper } from "./field.styles"; +import { PREFIX_CLS } from "../../constants"; +import withDefaultErrorMessage from "../../utils/default-error-message"; +import { ErrorMessage as ErrorMessageWrapper } from "@hookform/error-message"; + +type ErrorAnimation = "shake" | "none"; + +export interface FieldProps extends UseControllerProps { + label?: string; + prefix?: ReactNode; + suffix?: ReactNode; + errorAnimation?: ErrorAnimation; + children: ReactElement; + className?: string; + style?: React.CSSProperties; +} + +const Field = (props: FieldProps) => { + const { + label, + prefix, + suffix, + children, + errorAnimation = "shake", + rules, + className, + style, + ...controllerProps + } = props; + + const { + formState: { errors }, + control, + } = useFormContext(); + const { isSubmitting } = useFormState(); + + const enhancedRules = withDefaultErrorMessage(label || "", rules); + const error = errors[controllerProps.name]; + const isFirstError = [...control._names.mount.values()].find((item) => errors[item]) === controllerProps.name; + + return ( + + {label && ( +
+ {prefix} + + {suffix} +
+ )} + ( + <> + {React.cloneElement(children, { + ...field, + id: controllerProps.name, + })} + + )} + /> + { + return ( + <> + {message && !isSubmitting && ( + + {message} + + )} + + ); + }} + /> +
+ ); +}; + +export default memo(Field); diff --git a/packages/apsara-ui/src/form-builder-v2/components/field/index.ts b/packages/apsara-ui/src/form-builder-v2/components/field/index.ts new file mode 100644 index 00000000..d7bba537 --- /dev/null +++ b/packages/apsara-ui/src/form-builder-v2/components/field/index.ts @@ -0,0 +1 @@ +export { default } from "./field"; diff --git a/packages/apsara-ui/src/form-builder-v2/constants/index.ts b/packages/apsara-ui/src/form-builder-v2/constants/index.ts new file mode 100644 index 00000000..53265837 --- /dev/null +++ b/packages/apsara-ui/src/form-builder-v2/constants/index.ts @@ -0,0 +1 @@ +export const PREFIX_CLS = "apsara-form-builder-v2"; diff --git a/packages/apsara-ui/src/form-builder-v2/form-builder-v2.stories.tsx b/packages/apsara-ui/src/form-builder-v2/form-builder-v2.stories.tsx new file mode 100644 index 00000000..65c27a29 --- /dev/null +++ b/packages/apsara-ui/src/form-builder-v2/form-builder-v2.stories.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import FormBuilderV2 from "./form-builder-v2"; +import { useForm } from "react-hook-form"; +import Input from "../Input"; +import Button from "../Button"; +import Radio from "../Radio"; +import Select from "../Select"; +import InputNumber from "../InputNumber"; +import DatePicker from "../DatePicker"; +import Combobox from "../Combobox"; +import Switch from "../Switch"; +import Checkbox from "../Checkbox"; + +export default { + title: "Data Display/FormV2", + component: FormBuilderV2, +}; + +export interface MyFormValues { + name: string; + address: number; + agreement: boolean; +} + +export const MyForm = () => { + const form = useForm(); + + const onSubmit = (data: MyFormValues) => { + console.log("Form Submitted:", data); + }; + + return ( + + + + + + + + + + + + + + + + + +