diff --git a/apps/docs/content/docs/components/autocomplete.mdx b/apps/docs/content/docs/components/autocomplete.mdx index 435dfa31a7..e14cfe1728 100644 --- a/apps/docs/content/docs/components/autocomplete.mdx +++ b/apps/docs/content/docs/components/autocomplete.mdx @@ -405,7 +405,8 @@ properties to customize the popover, listbox and input components. | selectedKey | `React.Key` | The currently selected key in the collection (controlled). | - | | defaultSelectedKey | `React.Key` | The initial selected key in the collection (uncontrolled). | - | | disabledKeys | `all` \| `React.Key[]` | The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. | - | -| errorMessage | `ReactNode` | An error message to display below the field. | - | +| errorMessage | `ReactNode` \| `((v: ValidationResult) => ReactNode)` | An error message to display below the field. | - | +| validate | `(value: { inputValue: string, selectedKey: React.Key }) => ValidationError | true | null | undefined` | Validate input values when committing (e.g. on blur), and return error messages for invalid values. | - | | startContent | `ReactNode` | Element to be rendered in the left side of the Autocomplete. | - | | endContent | `ReactNode` | Element to be rendered in the right side of the Autocomplete. | - | | autoFocus | `boolean` | Whether the Autocomplete should be focused on render. | `false` | diff --git a/apps/docs/content/docs/components/checkbox-group.mdx b/apps/docs/content/docs/components/checkbox-group.mdx index f6facbdcd3..c32a5e22d7 100644 --- a/apps/docs/content/docs/components/checkbox-group.mdx +++ b/apps/docs/content/docs/components/checkbox-group.mdx @@ -87,14 +87,15 @@ In case you need to customize the checkbox even further, you can use the `useChe | size | `xs` \| `sm` \| `md` \| `lg` \| `xl` | The size of the checkboxes. | `md` | | radius | `none` \| `base` \| `xs` \| `sm` \| `md` \| `lg` \| `xl` \| `full` | The radius of the checkboxes. | `md` | | name | `string` | The name of the CheckboxGroup, used when submitting an HTML form. | - | -| label | `string` | The label of the CheckboxGroup. | - | +| label | `string` | The label of the CheckboxGroup. | - | | value | `string[]` | The current selected values. (controlled). | - | | lineThrough | `boolean` | Whether the checkboxes label should be crossed out. | `false` | | defaultValue | `string[]` | The default selected values. (uncontrolled). | - | | isInvalid | `boolean` | Whether the checkbox group is invalid. | `false` | | validationState | `valid` \| `invalid` | Whether the inputs should display its "valid" or "invalid" visual styling. (**Deprecated**) use **isInvalid** instead. | - | | description | `ReactNode` | The checkbox group description. | - | -| errorMessage | `ReactNode` | The checkbox group error message. | - | +| errorMessage | `ReactNode` \| `((v: ValidationResult) => ReactNode)` | The checkbox group error message. | - | +| validate | `(value: string[]) => ValidationError | true | null | undefined` | Validate input values when committing (e.g. on blur), and return error messages for invalid values. | - | | isDisabled | `boolean` | Whether the checkbox group is disabled. | `false` | | isRequired | `boolean` | Whether user checkboxes are required on the input before form submission. | `false` | | isReadOnly | `boolean` | Whether the checkboxes can be selected but not changed by the user. | - | @@ -103,6 +104,6 @@ In case you need to customize the checkbox even further, you can use the `useChe ### Checkbox Group Events -| Attribute | Type | Description | -| --------- | ----------------------------- | ---------------------------------------------- | -| onChange | `((value: string[]) => void)` | Handler that is called when the value changes. | +| Attribute | Type | Description | +| --------- | --------------------------- | ---------------------------------------------- | +| onChange | `(value: string[]) => void` | Handler that is called when the value changes. | diff --git a/apps/docs/content/docs/components/input.mdx b/apps/docs/content/docs/components/input.mdx index 4b590d3435..64cf189514 100644 --- a/apps/docs/content/docs/components/input.mdx +++ b/apps/docs/content/docs/components/input.mdx @@ -192,7 +192,8 @@ In case you need to customize the input even further, you can use the `useInput` | defaultValue | `string` | The default value of the input (uncontrolled). | - | | placeholder | `string` | The placeholder of the input. | - | | description | `ReactNode` | A description for the input. Provides a hint such as specific requirements for what to choose. | - | -| errorMessage | `ReactNode` | An error message for the input. | - | +| errorMessage | `ReactNode` \| `((v: ValidationResult) => ReactNode)` | An error message for the input. | - | +| validate | `(value: string) => ValidationError | true | null | undefined` | Validate input values when committing (e.g. on blur), and return error messages for invalid values. | - | | startContent | `ReactNode` | Element to be rendered in the left side of the input. | - | | endContent | `ReactNode` | Element to be rendered in the right side of the input. | - | | labelPlacement | `inside` \| `outside` \| `outside-left` | The position of the label. | `inside` | diff --git a/apps/docs/content/docs/components/radio-group.mdx b/apps/docs/content/docs/components/radio-group.mdx index 8da1a379df..9df97f4194 100644 --- a/apps/docs/content/docs/components/radio-group.mdx +++ b/apps/docs/content/docs/components/radio-group.mdx @@ -145,7 +145,8 @@ In case you need to customize the radio group even further, you can use the `use | value | `string[]` | The current selected value. (controlled). | - | | defaultValue | `string[]` | The default selected value. (uncontrolled). | - | | description | `ReactNode` | Radio group description . | - | -| errorMessage | `ReactNode` | Radio group error message. | - | +| errorMessage | `ReactNode` \| `((v: ValidationResult) => ReactNode)` | Radio group error message. | - | +| validate | `(value: string) => ValidationError | true | null | undefined` | Validate input values when committing (e.g. on blur), and return error messages for invalid values. | - | | isDisabled | `boolean` | Whether the radio group is disabled. | `false` | | isRequired | `boolean` | Whether user checkboxes are required on the input before form submission. | `false` | | isReadOnly | `boolean` | Whether the checkboxes can be selected but not changed by the user. | - | diff --git a/apps/docs/content/docs/components/select.mdx b/apps/docs/content/docs/components/select.mdx index b366a2a640..256c7020e5 100644 --- a/apps/docs/content/docs/components/select.mdx +++ b/apps/docs/content/docs/components/select.mdx @@ -349,7 +349,7 @@ the popover and listbox components. | labelPlacement | `inside` \| `outside` \| `outside-left` | The position of the label. | `inside` | | label | `ReactNode` | The content to display as the label. | - | | description | `ReactNode` | A description for the select. Provides a hint such as specific requirements for what to choose. | - | -| errorMessage | `ReactNode` | An error message for the select. | - | +| errorMessage | `ReactNode` \| `((v: ValidationResult) => ReactNode)` | An error message for the select. | - | | startContent | `ReactNode` | Element to be rendered in the left side of the select. | - | | endContent | `ReactNode` | Element to be rendered in the right side of the select. | - | | selectorIcon | `ReactNode` | Element to be rendered as the selector icon. | - | diff --git a/apps/docs/content/docs/components/textarea.mdx b/apps/docs/content/docs/components/textarea.mdx index 738b3befff..4c666f9e89 100644 --- a/apps/docs/content/docs/components/textarea.mdx +++ b/apps/docs/content/docs/components/textarea.mdx @@ -142,7 +142,8 @@ You can use the `value` and `onValueChange` properties to control the input valu | startContent | `ReactNode` | Element to be rendered in the left side of the input. | - | | endContent | `ReactNode` | Element to be rendered in the right side of the input. | - | | description | `ReactNode` | A description for the textarea. Provides a hint such as specific requirements for what to choose. | - | -| errorMessage | `ReactNode` | An error message for the textarea. | - | +| errorMessage | `ReactNode` \| `((v: ValidationResult) => ReactNode)` | An error message for the textarea. | - | +| validate | `(value: string) => ValidationError | true | null | undefined` | Validate input values when committing (e.g. on blur), and return error messages for invalid values. | - | | labelPlacement | `inside` \| `outside` \| `outside-left` | The position of the label. | `inside` | | fullWidth | `boolean` | Whether the textarea should take up the width of its parent. | `true` | | isRequired | `boolean` | Whether user input is required on the textarea before form submission. | `false` | @@ -152,7 +153,7 @@ You can use the `value` and `onValueChange` properties to control the input valu | validationState | `valid` \| `invalid` | Whether the textarea should display its "valid" or "invalid" visual styling. (**Deprecated**) use **isInvalid** instead. | - | | disableAutosize | `boolean` | Whether the textarea auto vertically resize should be disabled. | `false` | | disableAnimation | `boolean` | Whether the textarea should be animated. | `false` | -| classNames | `Record<"base"| "label"| "inputWrapper"| "innerWrapper" | "input" | "description" | "errorMessage", string>` | Allows to set custom class names for the checkbox slots. | - | +| classNames | `Record<"base"| "label"| "inputWrapper"| "innerWrapper" | "input" | "description" | "errorMessage", string>` | Allows to set custom class names for the checkbox slots. | - | ### Input Events diff --git a/apps/docs/package.json b/apps/docs/package.json index 4ac5832ee3..76ea49018b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -101,7 +101,7 @@ "@docusaurus/utils": "2.0.0-beta.3", "@next/bundle-analyzer": "^13.4.6", "@next/env": "^13.4.12", - "@react-types/shared": "3.21.0", + "@react-types/shared": "^3.22.0", "@tailwindcss/typography": "^0.5.9", "@types/canvas-confetti": "^1.4.2", "@types/lodash": "^4.14.194", diff --git a/package.json b/package.json index 98a239dd36..3291758976 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@commitlint/config-conventional": "^17.2.0", "@react-bootstrap/babel-preset": "^2.1.0", "@react-types/link": "^3.4.4", - "@react-types/shared": "3.21.0", + "@react-types/shared": "^3.22.0", "@swc-node/jest": "^1.5.2", "@swc/core": "^1.3.35", "@swc/jest": "^0.2.24", diff --git a/packages/components/accordion/package.json b/packages/components/accordion/package.json index 3d28ceea30..7cb9f5e636 100644 --- a/packages/components/accordion/package.json +++ b/packages/components/accordion/package.json @@ -54,14 +54,13 @@ "@nextui-org/framer-utils": "workspace:*", "@nextui-org/divider": "workspace:*", "@nextui-org/use-aria-accordion": "workspace:*", - "@nextui-org/use-aria-press": "workspace:*", - "@react-aria/interactions": "^3.19.1", - "@react-aria/focus": "^3.14.3", - "@react-aria/utils": "^3.21.1", - "@react-stately/tree": "^3.7.3", - "@react-aria/button": "^3.8.4", - "@react-types/accordion": "3.0.0-alpha.17", - "@react-types/shared": "3.21.0" + "@react-aria/interactions": "^3.21.1", + "@react-aria/focus": "^3.16.2", + "@react-aria/utils": "^3.23.2", + "@react-stately/tree": "^3.7.6", + "@react-aria/button": "^3.9.3", + "@react-types/accordion": "3.0.0-alpha.19", + "@react-types/shared": "^3.22.1" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/accordion/src/use-accordion-item.ts b/packages/components/accordion/src/use-accordion-item.ts index a4ae78afab..ec4f85ef09 100644 --- a/packages/components/accordion/src/use-accordion-item.ts +++ b/packages/components/accordion/src/use-accordion-item.ts @@ -7,8 +7,7 @@ import {NodeWithProps} from "@nextui-org/aria-utils"; import {useReactAriaAccordionItem} from "@nextui-org/use-aria-accordion"; import {useCallback, useMemo} from "react"; import {chain, mergeProps} from "@react-aria/utils"; -import {useHover} from "@react-aria/interactions"; -import {usePress} from "@nextui-org/use-aria-press"; +import {useHover, usePress} from "@react-aria/interactions"; import {TreeState} from "@react-stately/tree"; import {AccordionItemBaseProps} from "./base/accordion-item-base"; diff --git a/packages/components/autocomplete/package.json b/packages/components/autocomplete/package.json index 670d2b62b9..679294e166 100644 --- a/packages/components/autocomplete/package.json +++ b/packages/components/autocomplete/package.json @@ -52,15 +52,15 @@ "@nextui-org/button": "workspace:*", "@nextui-org/use-aria-button": "workspace:*", "@nextui-org/shared-icons": "workspace:*", - "@react-aria/combobox": "^3.7.1", - "@react-aria/focus": "^3.14.3", - "@react-aria/i18n": "^3.8.4", - "@react-aria/interactions": "^3.19.1", - "@react-aria/utils": "^3.21.1", - "@react-aria/visually-hidden": "^3.8.6", - "@react-stately/combobox": "^3.7.1", - "@react-types/combobox": "^3.8.1", - "@react-types/shared": "3.21.0" + "@react-aria/combobox": "^3.8.4", + "@react-aria/focus": "^3.16.2", + "@react-aria/i18n": "^3.10.2", + "@react-aria/interactions": "^3.21.1", + "@react-aria/utils": "^3.23.2", + "@react-aria/visually-hidden": "^3.8.10", + "@react-stately/combobox": "^3.8.2", + "@react-types/combobox": "^3.10.1", + "@react-types/shared": "^3.22.1" }, "devDependencies": { "@nextui-org/theme": "workspace:*", @@ -69,7 +69,7 @@ "@nextui-org/chip": "workspace:*", "@nextui-org/stories-utils": "workspace:*", "@nextui-org/use-infinite-scroll": "workspace:*", - "@react-stately/data": "^3.10.3", + "@react-stately/data": "^3.11.0", "framer-motion": "^10.16.4", "clean-package": "2.2.0", "react": "^18.0.0", diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index b1ddeb625f..36ae384627 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -3,7 +3,6 @@ import type {AutocompleteVariantProps, SlotsToClasses, AutocompleteSlots} from " import {DOMAttributes, HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system"; import {autocomplete} from "@nextui-org/theme"; import {useFilter} from "@react-aria/i18n"; -import {useComboBox} from "@react-aria/combobox"; import {FilterFn, useComboBoxState} from "@react-stately/combobox"; import {ReactRef, useDOMRef} from "@nextui-org/react-utils"; import {ReactNode, useCallback, useEffect, useMemo, useRef} from "react"; @@ -16,6 +15,7 @@ import {ScrollShadowProps} from "@nextui-org/scroll-shadow"; import {chain, mergeProps} from "@react-aria/utils"; import {ButtonProps} from "@nextui-org/button"; import {AsyncLoadable, PressEvent} from "@react-types/shared"; +import {useComboBox} from "@react-aria/combobox"; interface Props extends Omit, keyof ComboBoxProps> { /** @@ -110,8 +110,11 @@ interface Props extends Omit, keyof ComboBoxProps } export type UseAutocompleteProps = Props & - Omit & - ComboBoxProps & + Omit< + InputProps, + "children" | "value" | "isClearable" | "defaultValue" | "classNames" | "validationBehavior" + > & + Omit, "validationBehavior"> & AsyncLoadable & AutocompleteVariantProps; @@ -154,6 +157,7 @@ export function useAutocomplete(originalProps: UseAutocomplete allowsCustomValue = false, className, classNames, + errorMessage, onOpenChange, onClose, isReadOnly = false, @@ -167,6 +171,7 @@ export function useAutocomplete(originalProps: UseAutocomplete ...originalProps, children, menuTrigger, + validationBehavior: "native", shouldCloseOnBlur, allowsEmptyCollection, defaultFilter: defaultFilter && typeof defaultFilter === "function" ? defaultFilter : contains, @@ -193,6 +198,27 @@ export function useAutocomplete(originalProps: UseAutocomplete const inputRef = useDOMRef(ref); const scrollShadowRef = useDOMRef(scrollRefProp); + const { + buttonProps, + inputProps, + listBoxProps, + isInvalid: isAriaInvalid, + validationDetails, + validationErrors, + } = useComboBox( + { + validationBehavior: "native", + ...originalProps, + inputRef, + buttonRef, + listBoxRef, + popoverRef, + }, + state, + ); + + const isInvalid = originalProps.isInvalid || isAriaInvalid; + const slotsProps: { inputProps: InputProps; popoverProps: UseAutocompleteProps["popoverProps"]; @@ -248,7 +274,7 @@ export function useAutocomplete(originalProps: UseAutocomplete size: "sm", variant: "light", radius: "full", - color: originalProps?.isInvalid ? "danger" : originalProps?.color, + color: isInvalid ? "danger" : originalProps?.color, isIconOnly: true, disableAnimation, }, @@ -259,7 +285,7 @@ export function useAutocomplete(originalProps: UseAutocomplete size: "sm", variant: "light", radius: "full", - color: originalProps?.isInvalid ? "danger" : originalProps?.color, + color: isInvalid ? "danger" : originalProps?.color, isIconOnly: true, disableAnimation, }, @@ -290,17 +316,6 @@ export function useAutocomplete(originalProps: UseAutocomplete } }, [isOpen, allowsCustomValue]); - const {buttonProps, inputProps, listBoxProps} = useComboBox( - { - ...originalProps, - inputRef, - buttonRef, - listBoxRef, - popoverRef, - }, - state, - ); - const Component = as || "div"; const slots = useMemo( @@ -328,7 +343,7 @@ export function useAutocomplete(originalProps: UseAutocomplete ); const getBaseProps: PropGetter = () => ({ - "data-invalid": dataAttr(originalProps?.isInvalid), + "data-invalid": dataAttr(isInvalid), "data-open": dataAttr(state.isOpen), className: slots.base({class: baseStyles}), }); @@ -369,6 +384,11 @@ export function useAutocomplete(originalProps: UseAutocomplete ...otherProps, ...inputProps, ...slotsProps.inputProps, + isInvalid, + errorMessage: + typeof errorMessage === "function" + ? errorMessage({isInvalid, validationErrors, validationDetails}) + : errorMessage || validationErrors.join(" "), onClick: chain(slotsProps.inputProps.onClick, otherProps.onClick), } as unknown as InputProps); diff --git a/packages/components/autocomplete/stories/autocomplete.stories.tsx b/packages/components/autocomplete/stories/autocomplete.stories.tsx index 32862908ad..a3b422bbcc 100644 --- a/packages/components/autocomplete/stories/autocomplete.stories.tsx +++ b/packages/components/autocomplete/stories/autocomplete.stories.tsx @@ -1,3 +1,5 @@ +import type {ValidationResult} from "@react-types/shared"; + import React, {Key} from "react"; import {Meta} from "@storybook/react"; import {autocomplete, input, button} from "@nextui-org/theme"; @@ -127,7 +129,7 @@ const DynamicTemplate = ({color, variant, ...args}: AutocompleteProps) = ); -const RequiredTemplate = ({color, variant, ...args}: AutocompleteProps) => { +const FormTemplate = ({color, variant, ...args}: AutocompleteProps) => { return (
{ }} > { + if (value.validationDetails.valueMissing) { + return "Value is required"; + } + }, + }, +}; + +export const WithValidation = { + render: FormTemplate, + + args: { + ...defaultProps, + isRequired: true, + validate: (value) => { + if (value.inputValue === "Cat" || value.selectedKey === "dog") { + return "Please select a valid animal"; + } + }, + }, +}; + export const IsInvalid = { render: Template, diff --git a/packages/components/avatar/package.json b/packages/components/avatar/package.json index 0fda1762b2..9f861393b7 100644 --- a/packages/components/avatar/package.json +++ b/packages/components/avatar/package.json @@ -43,9 +43,9 @@ "@nextui-org/shared-utils": "workspace:*", "@nextui-org/react-utils": "workspace:*", "@nextui-org/use-image": "workspace:*", - "@react-aria/interactions": "^3.19.1", - "@react-aria/focus": "^3.14.3", - "@react-aria/utils": "^3.21.1" + "@react-aria/interactions": "^3.21.1", + "@react-aria/focus": "^3.16.2", + "@react-aria/utils": "^3.23.2" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/breadcrumbs/package.json b/packages/components/breadcrumbs/package.json index d68dbe444a..4ce9f62d42 100644 --- a/packages/components/breadcrumbs/package.json +++ b/packages/components/breadcrumbs/package.json @@ -43,11 +43,11 @@ "@nextui-org/react-utils": "workspace:*", "@nextui-org/shared-utils": "workspace:*", "@nextui-org/shared-icons": "workspace:*", - "@react-aria/focus": "^3.14.3", - "@react-aria/breadcrumbs": "^3.5.7", - "@react-aria/utils": "^3.21.1", - "@react-types/breadcrumbs": "^3.7.1", - "@react-types/shared": "3.21.0" + "@react-aria/focus": "^3.16.2", + "@react-aria/breadcrumbs": "^3.5.11", + "@react-aria/utils": "^3.23.2", + "@react-types/breadcrumbs": "^3.7.3", + "@react-types/shared": "^3.22.1" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/button/package.json b/packages/components/button/package.json index 614ba43643..91a4f66648 100644 --- a/packages/components/button/package.json +++ b/packages/components/button/package.json @@ -46,12 +46,12 @@ "@nextui-org/use-aria-button": "workspace:*", "@nextui-org/ripple": "workspace:*", "@nextui-org/spinner": "workspace:*", - "@react-aria/button": "^3.8.4", - "@react-aria/interactions": "^3.19.1", - "@react-aria/utils": "^3.21.1", - "@react-aria/focus": "^3.14.3", - "@react-types/shared": "3.21.0", - "@react-types/button": "^3.9.0" + "@react-aria/button": "^3.9.3", + "@react-aria/interactions": "^3.21.1", + "@react-aria/utils": "^3.23.2", + "@react-aria/focus": "^3.16.2", + "@react-types/shared": "^3.22.1", + "@react-types/button": "^3.9.2" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/card/package.json b/packages/components/card/package.json index a34d2ee397..65e27741c4 100644 --- a/packages/components/card/package.json +++ b/packages/components/card/package.json @@ -45,11 +45,11 @@ "@nextui-org/react-utils": "workspace:*", "@nextui-org/use-aria-button": "workspace:*", "@nextui-org/ripple": "workspace:*", - "@react-aria/focus": "^3.14.3", - "@react-aria/utils": "^3.21.1", - "@react-aria/interactions": "^3.19.1", - "@react-aria/button": "^3.8.4", - "@react-types/shared": "3.21.0" + "@react-aria/focus": "^3.16.2", + "@react-aria/utils": "^3.23.2", + "@react-aria/interactions": "^3.21.1", + "@react-aria/button": "^3.9.3", + "@react-types/shared": "^3.22.1" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/checkbox/__tests__/checkbox-group.test.tsx b/packages/components/checkbox/__tests__/checkbox-group.test.tsx index 4fb861df73..cef6cdd5b1 100644 --- a/packages/components/checkbox/__tests__/checkbox-group.test.tsx +++ b/packages/components/checkbox/__tests__/checkbox-group.test.tsx @@ -54,7 +54,7 @@ describe("Checkbox.Group", () => { act(() => (value = val))} + onChange={(val) => act(() => (value = val as string[]))} > Sydney @@ -132,4 +132,55 @@ describe("Checkbox.Group", () => { expect(onChange).toHaveBeenCalledTimes(1); expect(checked).toEqual(["sydney", "buenos-aires"]); }); + + describe("validation", () => { + let user = userEvent.setup(); + + beforeAll(() => { + user = userEvent.setup(); + }); + describe("validationBehavior=native (default)", () => { + it("supports group level isRequired", async () => { + let {getAllByRole, getByRole, getByTestId} = render( + + + Terms and conditions + Cookies + Privacy policy + + , + ); + + let group = getByRole("group"); + + expect(group).not.toHaveAttribute("aria-describedby"); + + let checkboxes = getAllByRole("checkbox") as HTMLInputElement[]; + + for (let input of checkboxes) { + expect(input).toHaveAttribute("required"); + expect(input).not.toHaveAttribute("aria-required"); + expect(input.validity.valid).toBe(false); + } + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(group).toHaveAttribute("aria-describedby"); + expect(document.getElementById(group.getAttribute("aria-describedby")!)).toHaveTextContent( + "Constraints not satisfied", + ); + + await user.click(checkboxes[0]); + for (let input of checkboxes) { + expect(input).not.toHaveAttribute("required"); + expect(input).not.toHaveAttribute("aria-required"); + expect(input.validity.valid).toBe(true); + } + + expect(group).not.toHaveAttribute("aria-describedby"); + }); + }); + }); }); diff --git a/packages/components/checkbox/package.json b/packages/components/checkbox/package.json index 4174f0e3d5..b31fdb963d 100644 --- a/packages/components/checkbox/package.json +++ b/packages/components/checkbox/package.json @@ -42,16 +42,15 @@ "dependencies": { "@nextui-org/shared-utils": "workspace:*", "@nextui-org/react-utils": "workspace:*", - "@nextui-org/use-aria-press": "workspace:*", - "@react-aria/checkbox": "^3.11.2", - "@react-aria/focus": "^3.14.3", - "@react-aria/interactions": "^3.19.1", - "@react-aria/visually-hidden": "^3.8.6", - "@react-stately/checkbox": "^3.5.1", - "@react-stately/toggle": "^3.6.3", - "@react-aria/utils": "^3.21.1", - "@react-types/checkbox": "^3.5.2", - "@react-types/shared": "3.21.0" + "@react-aria/checkbox": "^3.14.1", + "@react-aria/focus": "^3.16.2", + "@react-aria/interactions": "^3.21.1", + "@react-aria/visually-hidden": "^3.8.10", + "@react-stately/checkbox": "^3.6.3", + "@react-stately/toggle": "^3.7.2", + "@react-aria/utils": "^3.23.2", + "@react-types/checkbox": "^3.7.1", + "@react-types/shared": "^3.22.1" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/checkbox/src/checkbox-group.tsx b/packages/components/checkbox/src/checkbox-group.tsx index c03fc669c6..5f4aec3dbd 100644 --- a/packages/components/checkbox/src/checkbox-group.tsx +++ b/packages/components/checkbox/src/checkbox-group.tsx @@ -1,4 +1,5 @@ import {forwardRef} from "@nextui-org/system"; +import {useMemo} from "react"; import {CheckboxGroupProvider} from "./checkbox-group-context"; import {UseCheckboxGroupProps, useCheckboxGroup} from "./use-checkbox-group"; @@ -11,6 +12,7 @@ const CheckboxGroup = forwardRef<"div", CheckboxGroupProps>((props, ref) => { context, label, description, + isInvalid, errorMessage, getGroupProps, getLabelProps, @@ -19,14 +21,16 @@ const CheckboxGroup = forwardRef<"div", CheckboxGroupProps>((props, ref) => { getErrorMessageProps, } = useCheckboxGroup({...props, ref}); + const errorMessageContent = useMemo(() => errorMessage, [isInvalid]); + return (
{label && {label}}
{children}
- {errorMessage ? ( -
{errorMessage}
+ {isInvalid && errorMessageContent ? ( +
{errorMessageContent}
) : description ? (
{description}
) : null} diff --git a/packages/components/checkbox/src/checkbox.tsx b/packages/components/checkbox/src/checkbox.tsx index b9c9a7f475..dea6db536e 100644 --- a/packages/components/checkbox/src/checkbox.tsx +++ b/packages/components/checkbox/src/checkbox.tsx @@ -17,10 +17,7 @@ const Checkbox = forwardRef<"input", CheckboxProps>((props, ref) => { getInputProps, getIconProps, getLabelProps, - } = useCheckbox({ - ...props, - ref, - }); + } = useCheckbox({...props, ref}); const clonedIcon = typeof icon === "function" diff --git a/packages/components/checkbox/src/use-checkbox-group.ts b/packages/components/checkbox/src/use-checkbox-group.ts index a29e3c96b7..c59dfa276e 100644 --- a/packages/components/checkbox/src/use-checkbox-group.ts +++ b/packages/components/checkbox/src/use-checkbox-group.ts @@ -3,9 +3,10 @@ import type {AriaCheckboxGroupProps} from "@react-types/checkbox"; import type {Orientation} from "@react-types/shared"; import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system"; import type {ReactRef} from "@nextui-org/react-utils"; +import type {CheckboxGroupProps} from "@react-types/checkbox"; import {useCallback, useMemo} from "react"; -import {mergeProps} from "@react-aria/utils"; +import {chain, mergeProps} from "@react-aria/utils"; import {checkboxGroup} from "@nextui-org/theme"; import {useCheckboxGroup as useReactAriaCheckboxGroup} from "@react-aria/checkbox"; import {CheckboxGroupState, useCheckboxGroupState} from "@react-stately/checkbox"; @@ -47,7 +48,7 @@ interface Props extends HTMLNextUIProps<"div"> { } export type UseCheckboxGroupProps = Omit & - AriaCheckboxGroupProps & + Omit & Partial< Pick< CheckboxProps, @@ -83,8 +84,6 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { lineThrough = false, isDisabled = false, disableAnimation = false, - validationState, - isInvalid = validationState === "invalid", isReadOnly, isRequired, onValueChange, @@ -98,40 +97,48 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { const domRef = useDOMRef(ref); - const checkboxGroupProps = useMemo( - () => ({ - value, - name, - "aria-label": safeAriaLabel(otherProps["aria-label"], label), - defaultValue, - isRequired, - isInvalid, - isReadOnly, - orientation, - onChange: onValueChange, + const checkboxGroupProps = useMemo(() => { + return { ...otherProps, - }), - [ value, name, - label, + "aria-label": safeAriaLabel(otherProps["aria-label"], label), defaultValue, isRequired, isReadOnly, - isInvalid, orientation, - onValueChange, - otherProps["aria-label"], - otherProps, - ], - ); + validationBehavior: "native", + isInvalid: props.isInvalid || props.validationState === "invalid", + onChange: chain(props.onChange, onValueChange), + }; + }, [ + value, + name, + label, + defaultValue, + isRequired, + isReadOnly, + orientation, + onValueChange, + props.isInvalid, + props.validationState, + otherProps["aria-label"], + otherProps, + ]); const groupState = useCheckboxGroupState(checkboxGroupProps); - const {labelProps, groupProps, descriptionProps, errorMessageProps} = useReactAriaCheckboxGroup( - checkboxGroupProps, - groupState, - ); + const { + labelProps, + groupProps, + descriptionProps, + errorMessageProps, + isInvalid: isAriaInvalid, + validationErrors, + validationDetails, + } = useReactAriaCheckboxGroup(checkboxGroupProps, groupState); + + let isInvalid = props.isInvalid || props.validationState === "invalid" || isAriaInvalid; const context = useMemo( () => ({ @@ -218,7 +225,11 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { label, context, description, - errorMessage, + isInvalid, + errorMessage: + typeof errorMessage === "function" + ? errorMessage({isInvalid, validationErrors, validationDetails}) + : errorMessage || validationErrors.join(" "), getGroupProps, getLabelProps, getWrapperProps, diff --git a/packages/components/checkbox/src/use-checkbox.ts b/packages/components/checkbox/src/use-checkbox.ts index 77b7697611..6d6e7a71a0 100644 --- a/packages/components/checkbox/src/use-checkbox.ts +++ b/packages/components/checkbox/src/use-checkbox.ts @@ -6,8 +6,7 @@ import {ReactNode, Ref, useCallback, useId, useState} from "react"; import {useMemo, useRef} from "react"; import {useToggleState} from "@react-stately/toggle"; import {checkbox} from "@nextui-org/theme"; -import {useHover} from "@react-aria/interactions"; -import {usePress} from "@nextui-org/use-aria-press"; +import {useHover, usePress} from "@react-aria/interactions"; import {useFocusRing} from "@react-aria/focus"; import {chain, mergeProps} from "@react-aria/utils"; import {useFocusableRef} from "@nextui-org/react-utils"; @@ -68,7 +67,7 @@ interface Props extends Omit, keyof CheckboxVariantProp } export type UseCheckboxProps = Omit & - Omit & + Omit & CheckboxVariantProps; export function useCheckbox(props: UseCheckboxProps = {}) { @@ -82,7 +81,7 @@ export function useCheckbox(props: UseCheckboxProps = {}) { children, icon, name, - isRequired = false, + isRequired, isReadOnly: isReadOnlyProp = false, autoFocus = false, isSelected: isSelectedProp, @@ -97,7 +96,6 @@ export function useCheckbox(props: UseCheckboxProps = {}) { isIndeterminate = false, defaultSelected, classNames, - onChange, className, onValueChange, ...otherProps @@ -120,9 +118,21 @@ export function useCheckbox(props: UseCheckboxProps = {}) { const Component = as || "label"; - const inputRef = useRef(null); + const inputRef = useRef(null); const domRef = useFocusableRef(ref as FocusableRef, inputRef); + // This workaround might become unnecessary once the following issue is resolved + // https://github.com/adobe/react-spectrum/issues/5693 + let onChange = props.onChange; + + if (isInGroup) { + const dispatch = () => { + groupContext.groupState.resetValidation(); + }; + + onChange = chain(dispatch, onChange); + } + const labelId = useId(); const ariaCheckboxProps = useMemo(() => { @@ -171,11 +181,12 @@ export function useCheckbox(props: UseCheckboxProps = {}) { { ...ariaCheckboxProps, isInvalid, + validationBehavior: "native", }, groupContext.groupState, inputRef, ) - : useReactAriaCheckbox(ariaCheckboxProps, useToggleState(ariaCheckboxProps), inputRef); // eslint-disable-line + : useReactAriaCheckbox({...ariaCheckboxProps, validationBehavior: "native",}, useToggleState(ariaCheckboxProps), inputRef); // eslint-disable-line const isInteractionDisabled = isDisabled || isReadOnly; diff --git a/packages/components/checkbox/stories/checkbox-group.stories.tsx b/packages/components/checkbox/stories/checkbox-group.stories.tsx index 310d43456c..e7a3d7381a 100644 --- a/packages/components/checkbox/stories/checkbox-group.stories.tsx +++ b/packages/components/checkbox/stories/checkbox-group.stories.tsx @@ -1,6 +1,9 @@ +import type {ValidationResult} from "@react-types/shared"; + import React from "react"; import {Meta} from "@storybook/react"; import {checkbox} from "@nextui-org/theme"; +import {button} from "@nextui-org/theme"; import {CheckboxGroup, Checkbox, CheckboxGroupProps} from "../src"; @@ -78,6 +81,32 @@ const InvalidTemplate = (args: CheckboxGroupProps) => { ); }; +const FormTemplate = (args: CheckboxGroupProps) => { + return ( +
{ + const formData = new FormData(e.currentTarget); + const selectedCities = formData.getAll("favorite-cities"); + + alert(`Submitted values: ${selectedCities.join(", ")}`); + e.preventDefault(); + }} + > + + Buenos Aires + Sydney + San Francisco + London + Tokyo + + +
+ ); +}; + export const Default = { render: Template, @@ -153,10 +182,41 @@ export const WithErrorMessage = { args: { ...defaultProps, + isInvalid: true, errorMessage: "The selected cities cannot be visited at the same time", }, }; +export const WithErrorMessageFunction = { + render: FormTemplate, + + args: { + ...defaultProps, + isRequired: true, + errorMessage: (value: ValidationResult) => { + if (value.validationDetails.valueMissing) { + return "At least one option must be selected"; + } + }, + }, +}; + +export const WithValidation = { + render: FormTemplate, + + args: { + ...defaultProps, + description: "Please select at least 2 options", + validate: (value: string[]) => { + if (value.length < 2) { + return "You must select at least 2 options"; + } + + return null; + }, + }, +}; + export const DisableAnimation = { render: Template, @@ -165,3 +225,12 @@ export const DisableAnimation = { disableAnimation: true, }, }; + +export const IsRequired = { + render: FormTemplate, + + args: { + ...defaultProps, + isRequired: true, + }, +}; diff --git a/packages/components/checkbox/stories/checkbox.stories.tsx b/packages/components/checkbox/stories/checkbox.stories.tsx index 7ac01baec0..a1838aa33d 100644 --- a/packages/components/checkbox/stories/checkbox.stories.tsx +++ b/packages/components/checkbox/stories/checkbox.stories.tsx @@ -2,6 +2,7 @@ import React from "react"; import {Meta} from "@storybook/react"; import {checkbox} from "@nextui-org/theme"; import {CloseIcon} from "@nextui-org/shared-icons"; +import {button} from "@nextui-org/theme"; import {Checkbox, CheckboxIconProps, CheckboxProps} from "../src"; @@ -63,6 +64,25 @@ const ControlledTemplate = (args: CheckboxProps) => { ); }; +const FormTemplate = (args: CheckboxProps) => { + return ( +
{ + alert(`Submitted value: ${e.target["check"].value}`); + e.preventDefault(); + }} + > + + Check + + +
+ ); +}; + export const Default = { args: { ...defaultProps, @@ -133,3 +153,12 @@ export const Controlled = { ...defaultProps, }, }; + +export const Required = { + render: FormTemplate, + + args: { + ...defaultProps, + isRequired: true, + }, +}; diff --git a/packages/components/chip/package.json b/packages/components/chip/package.json index 0de2778cf7..819a262620 100644 --- a/packages/components/chip/package.json +++ b/packages/components/chip/package.json @@ -43,11 +43,10 @@ "@nextui-org/shared-icons": "workspace:*", "@nextui-org/shared-utils": "workspace:*", "@nextui-org/react-utils": "workspace:*", - "@nextui-org/use-aria-press": "workspace:*", - "@react-aria/focus": "^3.14.3", - "@react-aria/interactions": "^3.19.1", - "@react-aria/utils": "^3.21.1", - "@react-types/checkbox": "^3.5.2" + "@react-aria/focus": "^3.16.2", + "@react-aria/interactions": "^3.21.1", + "@react-aria/utils": "^3.23.2", + "@react-types/checkbox": "^3.7.1" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/chip/src/use-chip.ts b/packages/components/chip/src/use-chip.ts index 2a5c8e0805..53eaa07db4 100644 --- a/packages/components/chip/src/use-chip.ts +++ b/packages/components/chip/src/use-chip.ts @@ -3,7 +3,7 @@ import type {ReactNode} from "react"; import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system"; import {mergeProps} from "@react-aria/utils"; -import {usePress} from "@nextui-org/use-aria-press"; +import {usePress} from "@react-aria/interactions"; import {useFocusRing} from "@react-aria/focus"; import {chip} from "@nextui-org/theme"; import {useDOMRef} from "@nextui-org/react-utils"; diff --git a/packages/components/divider/package.json b/packages/components/divider/package.json index 2483934736..c2994f7b82 100644 --- a/packages/components/divider/package.json +++ b/packages/components/divider/package.json @@ -42,7 +42,7 @@ "@nextui-org/shared-utils": "workspace:*", "@nextui-org/react-rsc-utils": "workspace:*", "@nextui-org/system-rsc": "workspace:*", - "@react-types/shared": "3.21.0" + "@react-types/shared": "^3.22.1" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/dropdown/package.json b/packages/components/dropdown/package.json index d4d3bf619f..acb49e502f 100644 --- a/packages/components/dropdown/package.json +++ b/packages/components/dropdown/package.json @@ -45,11 +45,11 @@ "@nextui-org/popover": "workspace:*", "@nextui-org/shared-utils": "workspace:*", "@nextui-org/react-utils": "workspace:*", - "@react-aria/menu": "^3.11.1", - "@react-aria/utils": "^3.21.1", - "@react-stately/menu": "^3.5.6", - "@react-aria/focus": "^3.14.3", - "@react-types/menu": "^3.9.5" + "@react-aria/menu": "^3.13.1", + "@react-aria/utils": "^3.23.2", + "@react-stately/menu": "^3.6.1", + "@react-aria/focus": "^3.16.2", + "@react-types/menu": "^3.9.7" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/input/__tests__/input.test.tsx b/packages/components/input/__tests__/input.test.tsx index a101e05ee1..d00bd400c0 100644 --- a/packages/components/input/__tests__/input.test.tsx +++ b/packages/components/input/__tests__/input.test.tsx @@ -49,7 +49,7 @@ describe("Input", () => { }); it("should have aria-describedby when errorMessage is provided", () => { - const {container} = render(); + const {container} = render(); expect(container.querySelector("input")).toHaveAttribute("aria-describedby"); }); diff --git a/packages/components/input/package.json b/packages/components/input/package.json index b8b024d44c..a6c7582760 100644 --- a/packages/components/input/package.json +++ b/packages/components/input/package.json @@ -43,14 +43,14 @@ "@nextui-org/react-utils": "workspace:*", "@nextui-org/shared-icons": "workspace:*", "@nextui-org/shared-utils": "workspace:*", - "@react-aria/focus": "^3.14.3", - "@react-aria/interactions": "^3.19.1", - "@react-aria/textfield": "^3.12.2", - "@react-aria/utils": "^3.21.1", - "@react-stately/utils": "^3.8.0", - "@react-types/shared": "3.21.0", - "@react-types/textfield": "^3.8.1", - "react-textarea-autosize": "^8.5.2" + "@react-aria/focus": "^3.16.2", + "@react-aria/interactions": "^3.21.1", + "@react-aria/textfield": "^3.14.3", + "@react-aria/utils": "^3.23.2", + "@react-stately/utils": "^3.9.1", + "@react-types/shared": "^3.22.1", + "@react-types/textfield": "^3.9.1", + "react-textarea-autosize": "^8.5.3" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/input/src/input.tsx b/packages/components/input/src/input.tsx index 67c4fd6df1..4c4c715a6b 100644 --- a/packages/components/input/src/input.tsx +++ b/packages/components/input/src/input.tsx @@ -19,6 +19,7 @@ const Input = forwardRef<"input", InputProps>((props, ref) => { isOutsideLeft, shouldLabelBeOutside, errorMessage, + isInvalid, getBaseProps, getLabelProps, getInputProps, @@ -46,7 +47,7 @@ const Input = forwardRef<"input", InputProps>((props, ref) => { return (
- {errorMessage ? ( + {isInvalid && errorMessage ? (
{errorMessage}
) : description ? (
{description}
@@ -55,6 +56,7 @@ const Input = forwardRef<"input", InputProps>((props, ref) => { ); }, [ hasHelper, + isInvalid, errorMessage, description, getHelperWrapperProps, diff --git a/packages/components/input/src/textarea.tsx b/packages/components/input/src/textarea.tsx index 60ad2d0b74..ace8ae4b53 100644 --- a/packages/components/input/src/textarea.tsx +++ b/packages/components/input/src/textarea.tsx @@ -78,6 +78,7 @@ const Textarea = forwardRef<"textarea", TextAreaProps>( hasHelper, shouldLabelBeOutside, shouldLabelBeInside, + isInvalid, errorMessage, getBaseProps, getLabelProps, @@ -144,7 +145,7 @@ const Textarea = forwardRef<"textarea", TextAreaProps>(
{hasHelper ? (
- {errorMessage ? ( + {isInvalid && errorMessage ? (
{errorMessage}
) : description ? (
{description}
diff --git a/packages/components/input/src/use-input.ts b/packages/components/input/src/use-input.ts index 8550a47076..8df33da56f 100644 --- a/packages/components/input/src/use-input.ts +++ b/packages/components/input/src/use-input.ts @@ -1,4 +1,5 @@ import type {InputVariantProps, SlotsToClasses, InputSlots} from "@nextui-org/theme"; +import type {AriaTextFieldOptions} from "@react-aria/textfield"; import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system"; import {AriaTextFieldProps} from "@react-types/textfield"; @@ -76,8 +77,10 @@ export interface Props void; } +type AutoCapitalize = AriaTextFieldOptions<"input">["autoCapitalize"]; + export type UseInputProps = - Props & Omit & InputVariantProps; + Props & Omit & InputVariantProps; export function useInput( originalProps: UseInputProps, @@ -92,7 +95,6 @@ export function useInput( + const [inputValue, setInputValue] = useControlledState( props.value, - props.defaultValue, + props.defaultValue ?? "", handleValueChange, ); @@ -141,9 +143,19 @@ export function useInput(() => { if ((!originalProps.labelPlacement || originalProps.labelPlacement === "inside") && !label) { @@ -184,6 +196,10 @@ export function useInput (
); +const FormTemplate = (args) => ( +
{ + alert(`Submitted value: ${e.target["example"].value}`); + e.preventDefault(); + }} + > + + +
+); + const PasswordTemplate = (args) => { const [isPasswordVisible, setIsPasswordVisible] = React.useState(false); @@ -465,7 +483,7 @@ export const Default = { }; export const Required = { - render: MirrorTemplate, + render: FormTemplate, args: { ...defaultProps, @@ -581,10 +599,53 @@ export const WithErrorMessage = { args: { ...defaultProps, + isInvalid: true, errorMessage: "Please enter a valid email address", }, }; +export const WithErrorMessageFunction = { + render: FormTemplate, + + args: { + ...defaultProps, + min: "0", + max: "100", + type: "number", + isRequired: true, + label: "Number", + placeholder: "Enter a number(0-100)", + errorMessage: (value: ValidationResult) => { + if (value.validationDetails.rangeOverflow) { + return "Value is too high"; + } + if (value.validationDetails.rangeUnderflow) { + return "Value is too low"; + } + if (value.validationDetails.valueMissing) { + return "Value is required"; + } + }, + }, +}; + +export const WithValidation = { + render: FormTemplate, + + args: { + ...defaultProps, + type: "number", + validate: (value) => { + if (value < 0 || value > 100) { + return "Value must be between 0 and 100"; + } + }, + isRequired: true, + label: "Number", + placeholder: "Enter a number(0-100)", + }, +}; + export const IsInvalid = { render: Template, diff --git a/packages/components/input/stories/textarea.stories.tsx b/packages/components/input/stories/textarea.stories.tsx index cddfb8df34..9c75c2445d 100644 --- a/packages/components/input/stories/textarea.stories.tsx +++ b/packages/components/input/stories/textarea.stories.tsx @@ -1,7 +1,10 @@ +import type {ValidationResult} from "@react-types/shared"; + import React from "react"; import {Meta} from "@storybook/react"; import {input} from "@nextui-org/theme"; import {SendFilledIcon, PlusFilledIcon} from "@nextui-org/shared-icons"; +import {button} from "@nextui-org/theme"; import {Textarea, TextAreaProps} from "../src"; @@ -99,6 +102,24 @@ const MaxRowsTemplate = (args: TextAreaProps) => (
); +const FormTemplate = (args: TextAreaProps) => ( +
{ + alert(`Submitted value: ${e.target["textarea"].value}`); + e.preventDefault(); + }} + > +
+