diff --git a/README.md b/README.md index c95b025..0ef71b4 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,20 @@ npm install @kleros/ui-components-library ### Setup -1. Import the CSS at the top level of your application: +1. Import the CSS: -```javascript -import "@kleros/ui-components-library/style.css"; -``` + a. For Non-tailwind apps, import the CSS at top level of your app. + + ```javascript + import "@kleros/ui-components-library/style.css"; + ``` + + b. For Tailwind apps, import the theme and mark the library as a source in your global.css file. + + ```css + @import "../../../node_modules/@kleros/ui-components-library/dist/assets/theme.css"; + @source "../../../node_modules/@kleros/ui-components-library"; + ``` 2. Import and use components in your application: @@ -82,8 +91,8 @@ function MyComponent() { If you wish the use the library's tailwind theme variables in your tailwind app. You can utilize it by importing the theme file in your `global.css` file. ```css -@import tailwindcss @import - "../../../node_modules/@kleros/ui-components-library/dist/assets/theme.css"; +@import tailwindcss; +@import "../../../node_modules/@kleros/ui-components-library/dist/assets/theme.css"; ``` You can find the available theme variables [here](src/styles/theme.css). diff --git a/package.json b/package.json index 4d48010..18ec098 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kleros/ui-components-library", - "version": "3.3.4", + "version": "3.4.5", "description": "UI components library which implements the Kleros design system.", "source": "./src/lib/index.ts", "main": "./dist/index.js", @@ -52,6 +52,7 @@ "@storybook/test": "^8.6.4", "@tailwindcss/postcss": "^4.0.11", "@tailwindcss/vite": "^4.1.4", + "@types/lodash": "^4", "@types/node": "^22.13.10", "@types/react": "^18.0.9", "@types/react-dom": "^18.0.3", @@ -93,6 +94,7 @@ "@internationalized/date": "^3.7.0", "bignumber.js": "^9.1.2", "clsx": "^2.1.1", + "lodash": "^4.17.21", "react": "^18.0.0", "react-aria-components": "^1.7.1", "react-dom": "^18.0.0", diff --git a/src/lib/accordion/accordion-item.tsx b/src/lib/accordion/accordion-item.tsx index f699275..7df4461 100644 --- a/src/lib/accordion/accordion-item.tsx +++ b/src/lib/accordion/accordion-item.tsx @@ -25,19 +25,24 @@ const AccordionItem: React.FC = ({ return (
= ({ "transition-[height] duration-(--klerosUIComponentsTransitionSpeed) ease-initial", )} > -
+
{body}
diff --git a/src/lib/container/modal.tsx b/src/lib/container/modal.tsx index 45e9a02..28b417b 100644 --- a/src/lib/container/modal.tsx +++ b/src/lib/container/modal.tsx @@ -14,6 +14,7 @@ interface ModalProps /** classname that applies to the modal overlay. */ modalOverlayClassname?: ModalOverlayProps["className"]; children?: DialogProps["children"]; + ariaLabel?: string; } /** A modal is an overlay element which blocks interaction with elements outside it. */ @@ -21,6 +22,7 @@ function Modal({ className, modalOverlayClassname, children, + ariaLabel, ...props }: Readonly) { return ( @@ -36,6 +38,7 @@ function Modal({ > , @@ -53,24 +48,26 @@ function DraggableList({ deletionDisabled = false, ...props }: Readonly) { - const list = useListData({ + const { + items: list, + moveAfter, + moveBefore, + remove, + getItem, + } = useList({ initialItems: items, + onChange: updateCallback, }); - useEffect(() => { - if (!updateCallback) return; - updateCallback(list.items); - }, [list, updateCallback, items]); - const { dragAndDropHooks } = useDragAndDrop({ getItems: (keys) => - [...keys].map((key) => ({ "text/plain": list.getItem(key)!.name })), + [...keys].map((key) => ({ "text/plain": getItem(key)!.name })), getAllowedDropOperations: () => ["move"], onReorder(e) { if (e.target.dropPosition === "before") { - list.moveBefore(e.target.key, e.keys); + moveBefore(e.target.key, e.keys); } else if (e.target.dropPosition === "after") { - list.moveAfter(e.target.key, e.keys); + moveAfter(e.target.key, e.keys); } }, renderDragPreview, @@ -81,11 +78,11 @@ function DraggableList({ {...props} aria-label={props["aria-label"] ?? "Reorderable list"} selectionMode="single" - items={list.items} + items={list} dragAndDropHooks={dragDisabled ? undefined : dragAndDropHooks} onSelectionChange={(keys) => { const keyArr = Array.from(keys); - const selectedItem = list.getItem(keyArr[0]); + const selectedItem = getItem(keyArr[0]); if (selectionCallback && selectedItem) selectionCallback(selectedItem); }} @@ -96,50 +93,54 @@ function DraggableList({ className, )} > - {(item) => ( - - cn( - "h-11.25 w-full cursor-pointer border-l-3 border-l-transparent", - "flex items-center gap-4 px-4", - "focus-visible:outline-klerosUIComponentsPrimaryBlue focus-visible:outline", - (isHovered || isSelected) && "bg-klerosUIComponentsMediumBlue", - isSelected && "border-l-klerosUIComponentsPrimaryBlue", - isDragging && "cursor-grabbing opacity-60", - ) - } - > - {({ isHovered }) => ( - <> - {dragDisabled ? null : ( - - )} - - {item.name} - - {isHovered && !deletionDisabled ? ( - - ) : null} - - )} - - )} + {list.map((item) => { + return ( + + cn( + "h-11.25 w-full cursor-pointer border-l-3 border-l-transparent", + "flex items-center gap-4 px-4", + "focus-visible:outline-klerosUIComponentsPrimaryBlue focus-visible:outline", + (isHovered || isSelected) && "bg-klerosUIComponentsMediumBlue", + isSelected && "border-l-klerosUIComponentsPrimaryBlue", + isDragging && "cursor-grabbing opacity-60", + ) + } + > + {({ isHovered, isSelected }) => ( + <> + {dragDisabled ? null : ( + + )} + + {item.name} + + {(isHovered || isSelected) && !deletionDisabled ? ( + + ) : null} + + )} + + ); + })} ); } diff --git a/src/lib/draggable-list/useList.ts b/src/lib/draggable-list/useList.ts new file mode 100644 index 0000000..2784075 --- /dev/null +++ b/src/lib/draggable-list/useList.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import _ from "lodash"; + +type Key = string | number; + +export interface ListItem { + id: Key; + name: string; + value: any; +} + +interface UseListOptions { + initialItems: ListItem[]; + onChange?: (updatedItems: ListItem[]) => void; +} + +export function useList({ initialItems, onChange }: UseListOptions) { + const [items, setItems] = useState(initialItems); + + // track updates to initialItems + useEffect(() => { + setItems((prevItems) => { + // preventing callback loop + if (_.isEqual(initialItems, prevItems)) return prevItems; + return initialItems; + }); + }, [initialItems]); + + const itemsMap = useMemo(() => { + const map = new Map(); + for (const item of items) { + map.set(item.id, item); + } + return map; + }, [items]); + + const getItem = useCallback((key: Key) => itemsMap.get(key), [itemsMap]); + + const remove = useCallback( + (key: Key) => { + // updateItems(items.filter((item) => key !== item.id)); + setItems((prevItems) => { + const newItems = prevItems.filter((item) => key !== item.id); + onChange?.(newItems); + return newItems; + }); + }, + [onChange], + ); + + const moveBefore = useCallback( + (targetKey: Key, keys: Iterable) => { + setItems((prevItems) => { + const key = Array.from(keys)[0]; + if (key === targetKey) return prevItems; + + const indexFrom = prevItems.findIndex((item) => item.id === key); + const indexTo = prevItems.findIndex((item) => item.id === targetKey); + if (indexFrom === -1 || indexTo === -1) return prevItems; + + const reordered = [...prevItems]; + const [movedItem] = reordered.splice(indexFrom, 1); + reordered.splice(indexTo, 0, movedItem); + onChange?.(reordered); + + return reordered; + }); + }, + [onChange], + ); + + const moveAfter = useCallback( + (targetKey: Key, keys: Iterable) => { + setItems((prevItems) => { + const key = Array.from(keys)[0]; + if (key === targetKey) return prevItems; + + const indexFrom = prevItems.findIndex((item) => item.id === key); + const indexTo = prevItems.findIndex((item) => item.id === targetKey); + if (indexFrom === -1 || indexTo === -1) return prevItems; + + const reordered = [...prevItems]; + const [movedItem] = reordered.splice(indexFrom, 1); + + // Adjust if removing item before target index + const insertIndex = indexFrom < indexTo ? indexTo : indexTo + 1; + reordered.splice(insertIndex, 0, movedItem); + onChange?.(reordered); + + return reordered; + }); + }, + [onChange], + ); + + return { + items, + getItem, + remove, + moveBefore, + moveAfter, + }; +} diff --git a/src/lib/form/bignumber-field/index.tsx b/src/lib/form/bignumber-field/index.tsx index e0375ae..b6157da 100644 --- a/src/lib/form/bignumber-field/index.tsx +++ b/src/lib/form/bignumber-field/index.tsx @@ -1,4 +1,4 @@ -import React, { useId } from "react"; +import React from "react"; import SuccessIcon from "../../../assets/svgs/status-icons/success.svg"; import WarningIcon from "../../../assets/svgs/status-icons/warning.svg"; import ErrorIcon from "../../../assets/svgs/status-icons/error.svg"; @@ -29,15 +29,10 @@ function BigNumberField({ placeholder, label, isDisabled, - id: propId, isReadOnly, name, ...props }: Readonly) { - // Generate an ID if one is not provided - const generatedId = useId(); - const id = propId || generatedId; - // Use our custom hook to get all the props and state const { inputProps, @@ -47,7 +42,8 @@ function BigNumberField({ groupProps, descriptionProps, errorMessageProps, - } = useBigNumberField({ id, isDisabled, placeholder, isReadOnly, ...props }); + validationResult, + } = useBigNumberField({ isDisabled, placeholder, isReadOnly, ...props }); return (
@@ -68,6 +64,7 @@ function BigNumberField({ <> @@ -181,6 +179,18 @@ function BigNumberField({ {message}
)} + {props.showFieldError && validationResult.isInvalid && ( + + {validationResult.validationError} + + )}
); } diff --git a/src/lib/form/bignumber-field/useBigNumberField.tsx b/src/lib/form/bignumber-field/useBigNumberField.tsx index 7c7b8c2..8b2ba14 100644 --- a/src/lib/form/bignumber-field/useBigNumberField.tsx +++ b/src/lib/form/bignumber-field/useBigNumberField.tsx @@ -6,6 +6,8 @@ import React, { ChangeEvent, FocusEvent, useRef, + useCallback, + useId, } from "react"; import BigNumber from "bignumber.js"; @@ -63,6 +65,15 @@ export interface BigNumberFieldProps { formatOptions?: FormatOptions; /** Additional props for the input element. */ inputProps?: React.InputHTMLAttributes; + /** A function that returns an error message if a given value is invalid. + * Return a string to denote invalid.*/ + validate?: (value: BigNumber | null) => true | null | undefined | string; + /** Flag to enable field errors, alternative to `message` + * This will show the validation errors from browser, or custom error in case `validate` is setup on Field. + */ + showFieldError?: boolean; + /** ClassName for field error message. */ + fieldErrorClassName?: string; } // Default format configuration @@ -78,18 +89,12 @@ const DEFAULT_FORMAT = { }; export function useBigNumberField(props: BigNumberFieldProps) { - // Configure BigNumber format + // Configure BigNumber exponential useEffect(() => { - const formatConfig = { - ...DEFAULT_FORMAT, - ...props.formatOptions, - }; - BigNumber.config({ EXPONENTIAL_AT: 1e9, - FORMAT: formatConfig, }); - }, [props.formatOptions]); + }, []); const { value, @@ -101,9 +106,20 @@ export function useBigNumberField(props: BigNumberFieldProps) { isDisabled, isReadOnly, isWheelDisabled, - id, + id: propId, + formatOptions, } = props; + // Generate an ID if one is not provided + const generatedId = useId(); + const id = propId || generatedId; + + const formatBigNumber = useCallback( + (value: BigNumber) => + value.toFormat({ ...DEFAULT_FORMAT, ...formatOptions }), + [formatOptions], + ); + const stepBig = new BigNumber(step.toString()).abs(); const minBig = minValue !== undefined ? new BigNumber(minValue.toString()) : undefined; @@ -113,10 +129,10 @@ export function useBigNumberField(props: BigNumberFieldProps) { // State for the input value const [inputValue, setInputValue] = useState(() => { if (value !== undefined) { - return new BigNumber(value.toString()).toFormat(); + return formatBigNumber(new BigNumber(value.toString())); } if (defaultValue !== undefined) { - return new BigNumber(defaultValue.toString()).toFormat(); + return formatBigNumber(new BigNumber(defaultValue.toString())); } return ""; }); @@ -124,6 +140,12 @@ export function useBigNumberField(props: BigNumberFieldProps) { // State to track if the input is currently formatted const [isFormatted, setIsFormatted] = useState(false); + // State to track input's validation + const [validationResult, setValidationResult] = useState<{ + isInvalid: boolean; + validationError?: string; + }>({ isInvalid: false }); + // State for the numeric value const [numberValue, setNumberValue] = useState(() => { try { @@ -165,7 +187,7 @@ export function useBigNumberField(props: BigNumberFieldProps) { if (numberValue !== null && !isFormatted) { formatTimerRef.current = window.setTimeout(() => { - setInputValue(numberValue.toFormat()); + setInputValue(formatBigNumber(numberValue)); setIsFormatted(true); formatTimerRef.current = null; }, 3000); @@ -177,7 +199,7 @@ export function useBigNumberField(props: BigNumberFieldProps) { formatTimerRef.current = null; } }; - }, [numberValue, isFormatted]); + }, [numberValue, isFormatted, formatBigNumber]); // Check if increment/decrement buttons should be disabled const canIncrement = (): boolean => { @@ -231,6 +253,7 @@ export function useBigNumberField(props: BigNumberFieldProps) { setInputValue(newValue.toString()); setIsFormatted(false); onChange?.(newValue); + setValidationResult(getValidationResult(newValue)); }; const decrement = () => { @@ -252,6 +275,7 @@ export function useBigNumberField(props: BigNumberFieldProps) { setInputValue(newValue.toString()); setIsFormatted(false); onChange?.(newValue); + setValidationResult(getValidationResult(newValue)); }; // Helper function to escape special characters in regex @@ -410,11 +434,13 @@ export function useBigNumberField(props: BigNumberFieldProps) { const handleBlur = () => { if (numberValue !== null) { // Format the number using BigNumber.toFormat - setInputValue(numberValue.toFormat()); + setInputValue(formatBigNumber(numberValue)); setIsFormatted(true); } else if (inputValue !== "" && inputValue !== "-") { setInputValue(""); } + + setValidationResult(getValidationResult()); }; // Handle keyboard events @@ -494,9 +520,35 @@ export function useBigNumberField(props: BigNumberFieldProps) { e.preventDefault(); }; + // prevent page scrolling when scrolling inside input + useEffect(() => { + const input = document.getElementById(id); + + const preventScroll = (e: globalThis.WheelEvent) => { + if (input && document.activeElement === input) { + // Stop page scroll + e.preventDefault(); + } + }; + + input?.addEventListener("wheel", preventScroll, { passive: false }); + + return () => { + input?.removeEventListener("wheel", preventScroll); + }; + }, [id]); + // Handle wheel events const handleWheel = (e: WheelEvent) => { - if (isDisabled || isReadOnly || isWheelDisabled) return; + const input = document.getElementById(id); + if ( + isDisabled || + isReadOnly || + isWheelDisabled || + // only scroll if input is in focus + document.activeElement !== input + ) + return; // If on a trackpad, users can scroll in both X and Y at once, check the magnitude of the change // if it's mostly in the X direction, then just return, the user probably doesn't mean to inc/dec @@ -552,6 +604,7 @@ export function useBigNumberField(props: BigNumberFieldProps) { readOnly: isReadOnly, required: props.isRequired, placeholder: props.placeholder, + "aria-invalid": validationResult?.isInvalid, ...getAriaAttributes(), ...props.inputProps, }); @@ -561,6 +614,32 @@ export function useBigNumberField(props: BigNumberFieldProps) { htmlFor: id, }); + // Field Error Render props + const getValidationResult = (value?: BigNumber) => { + const fieldErrorProps = { + isInvalid: false, + validationError: "", + }; + + if ( + props.isRequired && + (value ? value.toString() : inputValue).trim() === "" + ) { + fieldErrorProps.isInvalid = true; + fieldErrorProps.validationError = "Please fill out this field."; + } + + const validate = props.validate; + if (validate) { + const result = validate(value ?? numberValue); + if (typeof result === "string") { + fieldErrorProps.isInvalid = true; + fieldErrorProps.validationError = result; + } + } + return fieldErrorProps; + }; + // Increment button props const getIncrementButtonProps = () => ({ type: "button" as const, @@ -603,6 +682,7 @@ export function useBigNumberField(props: BigNumberFieldProps) { groupProps: getGroupProps(), descriptionProps: getDescriptionProps(), errorMessageProps: getErrorMessageProps(), + validationResult, inputValue, numberValue, canIncrement: canIncrement(), diff --git a/src/lib/form/number-field.tsx b/src/lib/form/number-field.tsx index 3f38c84..e494abf 100644 --- a/src/lib/form/number-field.tsx +++ b/src/lib/form/number-field.tsx @@ -15,6 +15,8 @@ import { type NumberFieldProps as AriaNumberFieldProps, Label, Text, + type FieldErrorProps, + FieldError, } from "react-aria-components"; import { cn } from "../../utils"; import clsx from "clsx"; @@ -29,6 +31,14 @@ interface NumberFieldProps extends AriaNumberFieldProps { */ inputProps?: InputProps; label?: string; + /** Flag to enable field errors, alternative to `message` + * This will show the validation errors from browser, or custom error in case `validate` is setup on Field. + */ + showFieldError?: boolean; + /** Props for FieldError in case `showFieldError` is true. + * [See FieldErrorProps](https://react-spectrum.adobe.com/react-aria/NumberField.html#fielderror) + */ + fieldErrorProps?: FieldErrorProps; } /** A number field allows a user to enter a number, and increment or decrement the value using stepper buttons. */ @@ -41,6 +51,8 @@ function NumberField({ label, isDisabled, inputProps, + showFieldError, + fieldErrorProps, ...props }: Readonly) { return ( @@ -170,6 +182,18 @@ function NumberField({ {message} )} + + {showFieldError && ( + + {fieldErrorProps?.children} + + )} ); } diff --git a/src/lib/form/searchbar.tsx b/src/lib/form/searchbar.tsx index 1068958..957f31e 100644 --- a/src/lib/form/searchbar.tsx +++ b/src/lib/form/searchbar.tsx @@ -1,6 +1,8 @@ import React from "react"; import SearchIcon from "../../assets/svgs/form/search.svg"; import { + FieldError, + type FieldErrorProps, Group, Input, type InputProps, @@ -18,6 +20,14 @@ interface SearchbarProps extends SearchFieldProps { * [See InputProps](https://react-spectrum.adobe.com/react-aria/NumberField.html#input-1) */ inputProps?: InputProps; + /** Flag to enable field errors, alternative to `message` + * This will show the validation errors from browser, or custom error in case `validate` is setup on Field. + */ + showFieldError?: boolean; + /** Props for FieldError in case `showFieldError` is true. + * [See FieldErrorProps](https://react-spectrum.adobe.com/react-aria/SearchField.html#fielderror) + */ + fieldErrorProps?: FieldErrorProps; } /** A search field allows a user to enter and clear a search query. */ function Searchbar({ @@ -25,6 +35,8 @@ function Searchbar({ placeholder, inputProps, className, + showFieldError, + fieldErrorProps, ...props }: Readonly) { return ( @@ -57,6 +69,17 @@ function Searchbar({ )} /> + {showFieldError && ( + + {fieldErrorProps?.children} + + )} ); } diff --git a/src/lib/form/text-area.tsx b/src/lib/form/text-area.tsx index f22e103..eeaf80f 100644 --- a/src/lib/form/text-area.tsx +++ b/src/lib/form/text-area.tsx @@ -12,6 +12,8 @@ import { type TextFieldProps, type TextAreaProps as AriaTextAreaProps, Text, + type FieldErrorProps, + FieldError, } from "react-aria-components"; import { cn } from "../../utils"; @@ -28,6 +30,14 @@ interface TextAreaProps extends TextFieldProps { resizeX?: boolean; /** Allow resizing along y-axis */ resizeY?: boolean; + /** Flag to enable field errors, alternative to `message` + * This will show the validation errors from browser, or custom error in case `validate` is setup on Field. + */ + showFieldError?: boolean; + /** Props for FieldError in case `showFieldError` is true. + * [See FieldErrorProps](https://react-spectrum.adobe.com/react-aria/TextField.html#fielderror) + */ + fieldErrorProps?: FieldErrorProps; } /** TextArea components supports multiline input and can be configured to resize. */ @@ -39,6 +49,8 @@ function TextArea({ placeholder, resizeX = false, resizeY = false, + showFieldError, + fieldErrorProps, ...props }: Readonly) { return ( @@ -101,6 +113,17 @@ function TextArea({ {message} )} + {showFieldError && ( + + {fieldErrorProps?.children} + + )} ); } diff --git a/src/lib/form/text-field.tsx b/src/lib/form/text-field.tsx index 1a8fcb8..82e7145 100644 --- a/src/lib/form/text-field.tsx +++ b/src/lib/form/text-field.tsx @@ -12,6 +12,8 @@ import { type TextFieldProps as AriaTextFieldProps, Label, Group, + FieldError, + type FieldErrorProps, } from "react-aria-components"; import { cn } from "../../utils"; @@ -25,6 +27,14 @@ interface TextFieldProps extends AriaTextFieldProps { */ inputProps?: InputProps; label?: string; + /** Flag to enable field errors, alternative to `message` + * This will show the validation errors from browser, or custom error in case `validate` is setup on Field. + */ + showFieldError?: boolean; + /** Props for FieldError in case `showFieldError` is true. + * [See FieldErrorProps](https://react-spectrum.adobe.com/react-aria/TextField.html#fielderror) + */ + fieldErrorProps?: FieldErrorProps; } /** A text field allows a user to enter a plain text value with a keyboard. */ function TextField({ @@ -34,6 +44,8 @@ function TextField({ className, placeholder, label, + showFieldError, + fieldErrorProps, ...props }: Readonly) { return ( @@ -109,6 +121,18 @@ function TextField({ {message} )} + + {showFieldError && ( + + {fieldErrorProps?.children} + + )} ); } diff --git a/src/stories/bignumber-field.stories.tsx b/src/stories/bignumber-field.stories.tsx index 0a0d6ac..25e11ea 100644 --- a/src/stories/bignumber-field.stories.tsx +++ b/src/stories/bignumber-field.stories.tsx @@ -1,8 +1,10 @@ +import React from "react"; import { Meta, StoryObj } from "@storybook/react"; import BigNumberField from "../lib/form/bignumber-field"; import Telegram from "../assets/svgs/telegram.svg"; import BigNumber from "bignumber.js"; import { IPreviewArgs } from "./utils"; +import { Button, Form } from "../lib"; const meta: Meta = { title: "Form/BigNumberField", @@ -183,3 +185,33 @@ export const ReadOnly: Story = { defaultValue: "42", }, }; + +/** Make a field required. Optionally you can choose to show the validation error and customize their style. */ +export const Required: Story = { + args: { + ...Default.args, + isRequired: true, + }, + render: (args) => ( +
{ + e.preventDefault(); + }} + > + (value?.eq(0) ? "Zero not allowed" : null)} + /> +
+ ); }, }; diff --git a/src/stories/number-field.stories.tsx b/src/stories/number-field.stories.tsx index 3a23279..fba5522 100644 --- a/src/stories/number-field.stories.tsx +++ b/src/stories/number-field.stories.tsx @@ -77,6 +77,7 @@ export const WithDescription: Story = { }, }; +/** Make a field required. Optionally you can choose to show the validation error and customize their style. */ export const Required: Story = { args: { ...Default.args, @@ -88,7 +89,25 @@ export const Required: Story = { e.preventDefault(); }} > - + (value === 0 ? "Zero not allowed." : null)} + fieldErrorProps={{ + children: ({ validationErrors }) => ( +
    + {validationErrors.map((error) => ( +
  • + {error} +
  • + ))} +
+ ), + }} + />