diff --git a/.changeset/fuzzy-lies-brush.md b/.changeset/fuzzy-lies-brush.md new file mode 100644 index 0000000000..9b1eb50573 --- /dev/null +++ b/.changeset/fuzzy-lies-brush.md @@ -0,0 +1,5 @@ +--- +"@nextui-org/table": minor +--- + +The `layoutNode` prop has been removed due to the update to react-aria. diff --git a/.changeset/polite-mails-kneel.md b/.changeset/polite-mails-kneel.md new file mode 100644 index 0000000000..d573540365 --- /dev/null +++ b/.changeset/polite-mails-kneel.md @@ -0,0 +1,47 @@ +--- +"@nextui-org/accordion": patch +"@nextui-org/avatar": patch +"@nextui-org/breadcrumbs": patch +"@nextui-org/button": patch +"@nextui-org/calendar": patch +"@nextui-org/card": patch +"@nextui-org/chip": patch +"@nextui-org/date-input": patch +"@nextui-org/divider": patch +"@nextui-org/dropdown": patch +"@nextui-org/kbd": patch +"@nextui-org/link": patch +"@nextui-org/listbox": patch +"@nextui-org/menu": patch +"@nextui-org/modal": patch +"@nextui-org/navbar": patch +"@nextui-org/pagination": patch +"@nextui-org/popover": patch +"@nextui-org/progress": patch +"@nextui-org/select": patch +"@nextui-org/slider": patch +"@nextui-org/snippet": patch +"@nextui-org/switch": patch +"@nextui-org/tabs": patch +"@nextui-org/tooltip": patch +"@nextui-org/user": patch +"@nextui-org/react": patch +"@nextui-org/system": patch +"@nextui-org/system-rsc": patch +"@nextui-org/use-aria-accordion": patch +"@nextui-org/use-aria-accordion-item": patch +"@nextui-org/use-aria-button": patch +"@nextui-org/use-aria-link": patch +"@nextui-org/use-aria-menu": patch +"@nextui-org/use-aria-modal-overlay": patch +"@nextui-org/use-aria-multiselect": patch +"@nextui-org/use-aria-overlay": patch +"@nextui-org/use-aria-toggle-button": patch +"@nextui-org/use-disclosure": patch +"@nextui-org/use-intersection-observer": patch +"@nextui-org/use-is-mobile": patch +"@nextui-org/use-pagination": patch +"@nextui-org/aria-utils": patch +--- + +update react-aria version diff --git a/.changeset/purple-berries-play.md b/.changeset/purple-berries-play.md new file mode 100644 index 0000000000..24f04e5ad5 --- /dev/null +++ b/.changeset/purple-berries-play.md @@ -0,0 +1,5 @@ +--- +"@nextui-org/form": major +--- + +add form component diff --git a/.changeset/sharp-pianos-pump.md b/.changeset/sharp-pianos-pump.md new file mode 100644 index 0000000000..5a9fb5e906 --- /dev/null +++ b/.changeset/sharp-pianos-pump.md @@ -0,0 +1,9 @@ +--- +"@nextui-org/autocomplete": minor +"@nextui-org/checkbox": minor +"@nextui-org/date-picker": minor +"@nextui-org/input": minor +"@nextui-org/radio": minor +--- + +support server validation with form diff --git a/.vscode/settings.json b/.vscode/settings.json index c531e733d0..6b36bf07d3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,5 @@ }, "tailwindCSS.experimental.classRegex": [ ["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"] - ] + ], } diff --git a/apps/docs/package.json b/apps/docs/package.json index 01e4bc4aba..501e799277 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -18,7 +18,7 @@ "@codesandbox/sandpack-react": "^2.6.4", "@iconify/icons-solar": "^1.2.3", "@iconify/react": "^4.1.1", - "@internationalized/date": "3.5.5", + "@internationalized/date": "3.5.6", "@mapbox/rehype-prism": "^0.6.0", "@nextui-org/aria-utils": "workspace:*", "@nextui-org/badge": "workspace:*", @@ -37,14 +37,14 @@ "@nextui-org/use-infinite-scroll": "workspace:*", "@nextui-org/use-is-mobile": "workspace:*", "@radix-ui/react-scroll-area": "^1.0.5", - "@react-aria/focus": "3.17.1", - "@react-aria/i18n": "3.11.1", - "@react-aria/interactions": "3.21.3", - "@react-aria/selection": "3.18.1", - "@react-aria/ssr": "3.9.4", - "@react-aria/utils": "3.24.1", + "@react-aria/focus": "3.18.4", + "@react-aria/i18n": "3.12.3", + "@react-aria/interactions": "3.22.4", + "@react-aria/selection": "3.20.1", + "@react-aria/ssr": "3.9.6", + "@react-aria/utils": "3.25.3", "@react-aria/virtualizer": "3.10.1", - "@react-aria/visually-hidden": "3.8.12", + "@react-aria/visually-hidden": "3.8.17", "@react-stately/data": "3.11.4", "@react-stately/layout": "3.13.9", "@react-stately/tree": "3.8.1", @@ -98,7 +98,7 @@ "@docusaurus/utils": "2.0.0-beta.3", "@next/bundle-analyzer": "^13.4.6", "@next/env": "^13.4.12", - "@react-types/shared": "3.24.1", + "@react-types/shared": "3.25.0", "@tailwindcss/typography": "^0.5.9", "@types/canvas-confetti": "^1.4.2", "@types/marked": "^5.0.0", diff --git a/package.json b/package.json index 5a3e849bd7..08fbeb1e38 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,8 @@ "@commitlint/cli": "^17.2.0", "@commitlint/config-conventional": "^17.2.0", "@react-bootstrap/babel-preset": "^2.1.0", - "@react-types/link": "^3.4.4", - "@react-types/shared": "3.24.1", + "@react-types/link": "3.5.7", + "@react-types/shared": "3.25.0", "@storybook/react": "^7.4.6", "@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 681011d80e..dcfcf8763d 100644 --- a/packages/components/accordion/package.json +++ b/packages/components/accordion/package.json @@ -55,13 +55,13 @@ "@nextui-org/divider": "workspace:*", "@nextui-org/use-aria-accordion": "workspace:*", "@nextui-org/dom-animation": "workspace:*", - "@react-aria/interactions": "3.22.2", - "@react-aria/focus": "3.18.2", - "@react-aria/utils": "3.25.2", - "@react-stately/tree": "3.8.4", - "@react-aria/button": "3.9.8", - "@react-types/accordion": "3.0.0-alpha.23", - "@react-types/shared": "3.24.1" + "@react-aria/interactions": "3.22.4", + "@react-aria/focus": "3.18.4", + "@react-aria/utils": "3.25.3", + "@react-stately/tree": "3.8.5", + "@react-aria/button": "3.10.1", + "@react-types/accordion": "3.0.0-alpha.24", + "@react-types/shared": "3.25.0" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/alert/package.json b/packages/components/alert/package.json index c6ab2e033a..455df85aa6 100644 --- a/packages/components/alert/package.json +++ b/packages/components/alert/package.json @@ -47,8 +47,8 @@ "@nextui-org/react-utils": "workspace:*", "@nextui-org/shared-icons": "workspace:*", "@nextui-org/shared-utils": "workspace:*", - "@react-stately/utils": "3.10.1", - "@react-aria/utils": "3.24.1", + "@react-stately/utils": "3.10.4", + "@react-aria/utils": "3.25.3", "@nextui-org/button": "workspace:*" }, "devDependencies": { diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index 5fa0b3a783..55a050d64c 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import {within, render, renderHook, act} from "@testing-library/react"; import userEvent, {UserEvent} from "@testing-library/user-event"; import {useForm} from "react-hook-form"; +import {Form} from "@nextui-org/form"; import {Autocomplete, AutocompleteItem, AutocompleteProps, AutocompleteSection} from "../src"; import {Modal, ModalContent, ModalBody, ModalHeader, ModalFooter} from "../../modal/src"; @@ -588,6 +589,60 @@ describe("Autocomplete", () => { expect(autocomplete2).toHaveFocus(); }); + it("should work when key equals textValue", async () => { + const wrapper = render( + + {(item) => {item.value}} + , + ); + + const autocomplete = wrapper.getByTestId("when-key-equals-textValue"); + + const user = userEvent.setup(); + + await user.click(autocomplete); + + expect(autocomplete).toHaveValue("cat"); + expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + + let listboxItems = wrapper.getAllByRole("option"); + + await user.click(listboxItems[1]); + + expect(autocomplete).toHaveValue("dog"); + expect(autocomplete).toHaveAttribute("aria-expanded", "false"); + }); + + it("should work when key equals textValue (controlled)", async () => { + const wrapper = render( + + {(item) => {item.value}} + , + ); + + const autocomplete = wrapper.getByTestId("when-key-equals-textValue"); + + const user = userEvent.setup(); + + await user.click(autocomplete); + + expect(autocomplete).toHaveValue("cat"); + expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + + let listboxItems = wrapper.getAllByRole("option"); + + await user.click(listboxItems[1]); + + expect(autocomplete).toHaveValue("dog"); + expect(autocomplete).toHaveAttribute("aria-expanded", "false"); + }); + describe("validation", () => { let user; @@ -598,9 +653,9 @@ describe("Autocomplete", () => { describe("validationBehavior=native", () => { it("supports isRequired", async () => { const {getByTestId, getByRole, findByRole} = render( -
+ - , + , ); const input = getByRole("combobox") as HTMLInputElement; @@ -628,6 +683,62 @@ describe("Autocomplete", () => { await user.click(items[0]); expect(input).toHaveAttribute("aria-describedby"); }); + + it("supports server validation", async () => { + function Test() { + const [serverErrors, setServerErrors] = React.useState({}); + const onSubmit = (e) => { + e.preventDefault(); + setServerErrors({ + value: "Invalid value.", + }); + }; + + return ( +
+ + + + ); + } + + const {getByTestId, getByRole} = render(); + + const input = getByTestId("input") as HTMLInputElement; + + expect(input).not.toHaveAttribute("aria-describedby"); + + await user.click(getByTestId("submit")); + + expect(input).toHaveAttribute("aria-describedby"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid value.", + ); + expect(input.validity.valid).toBe(false); + + await user.tab({shift: true}); + await user.keyboard("[ArrowRight]Ze"); + + act(() => { + jest.runAllTimers(); + }); + + const listbox = getByRole("listbox"); + const items = within(listbox).getAllByRole("option"); + + await user.click(items[0]); + act(() => { + jest.runAllTimers(); + }); + + expect(input).toHaveAttribute("aria-describedby"); + await user.tab(); + + expect(input).not.toHaveAttribute("aria-describedby"); + expect(input.validity.valid).toBe(true); + }); }); describe("validationBehavior=aria", () => { @@ -635,8 +746,8 @@ describe("Autocomplete", () => { let {getByRole, findByRole} = render(
(v.inputValue === "Penguin" ? "Invalid value" : null)} + defaultSelectedKey="penguin" + validate={(v) => (v.selectedKey === "penguin" ? "Invalid value" : null)} validationBehavior="aria" /> , @@ -664,61 +775,42 @@ describe("Autocomplete", () => { expect(input).not.toHaveAttribute("aria-describedby"); expect(input).not.toHaveAttribute("aria-invalid"); }); - }); - }); - it("should work when key equals textValue", async () => { - const wrapper = render( - - {(item) => {item.value}} - , - ); - - const autocomplete = wrapper.getByTestId("when-key-equals-textValue"); - - const user = userEvent.setup(); - - await user.click(autocomplete); - - expect(autocomplete).toHaveValue("cat"); - expect(autocomplete).toHaveAttribute("aria-expanded", "true"); - - let listboxItems = wrapper.getAllByRole("option"); - - await user.click(listboxItems[1]); - - expect(autocomplete).toHaveValue("dog"); - expect(autocomplete).toHaveAttribute("aria-expanded", "false"); - }); - - it("should work when key equals textValue (controlled)", async () => { - const wrapper = render( - - {(item) => {item.value}} - , - ); + it("supports server validation", async () => { + const {getByTestId, getByRole} = render( +
+ + , + ); - const autocomplete = wrapper.getByTestId("when-key-equals-textValue"); + const input = getByTestId("input"); - const user = userEvent.setup(); + expect(input).toHaveAttribute("aria-describedby"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid value", + ); - await user.click(autocomplete); + await user.tab(); + await user.keyboard("[ArrowRight]Ze"); - expect(autocomplete).toHaveValue("cat"); - expect(autocomplete).toHaveAttribute("aria-expanded", "true"); + act(() => { + jest.runAllTimers(); + }); - let listboxItems = wrapper.getAllByRole("option"); + const listbox = getByRole("listbox"); + const items = within(listbox).getAllByRole("option"); - await user.click(listboxItems[1]); + await user.click(items[0]); + act(() => { + jest.runAllTimers(); + }); - expect(autocomplete).toHaveValue("dog"); - expect(autocomplete).toHaveAttribute("aria-expanded", "false"); + await user.tab(); + expect(input).not.toHaveAttribute("aria-describedby"); + expect(input).not.toHaveAttribute("aria-invalid"); + }); + }); }); }); diff --git a/packages/components/autocomplete/package.json b/packages/components/autocomplete/package.json index adf887b029..38e2c4a5d4 100644 --- a/packages/components/autocomplete/package.json +++ b/packages/components/autocomplete/package.json @@ -41,6 +41,7 @@ "react-dom": ">=18" }, "dependencies": { + "@nextui-org/form": "workspace:*", "@nextui-org/aria-utils": "workspace:*", "@nextui-org/button": "workspace:*", "@nextui-org/input": "workspace:*", @@ -53,15 +54,15 @@ "@nextui-org/spinner": "workspace:*", "@nextui-org/use-aria-button": "workspace:*", "@nextui-org/use-safe-layout-effect": "workspace:*", - "@react-aria/combobox": "3.10.3", - "@react-aria/focus": "3.18.2", - "@react-aria/i18n": "3.12.2", - "@react-aria/interactions": "3.22.2", - "@react-aria/utils": "3.25.2", - "@react-aria/visually-hidden": "3.8.15", - "@react-stately/combobox": "3.9.2", - "@react-types/combobox": "3.12.1", - "@react-types/shared": "3.24.1" + "@react-aria/combobox": "3.10.5", + "@react-aria/focus": "3.18.4", + "@react-aria/i18n": "3.12.3", + "@react-aria/interactions": "3.22.4", + "@react-aria/utils": "3.25.3", + "@react-aria/visually-hidden": "3.8.17", + "@react-stately/combobox": "3.10.0", + "@react-types/combobox": "3.13.0", + "@react-types/shared": "3.25.0" }, "devDependencies": { "@nextui-org/avatar": "workspace:*", @@ -70,7 +71,7 @@ "@nextui-org/system": "workspace:*", "@nextui-org/theme": "workspace:*", "@nextui-org/use-infinite-scroll": "workspace:*", - "@react-stately/data": "3.11.6", + "@react-stately/data": "3.11.7", "clean-package": "2.2.0", "framer-motion": "11.9.0", "react": "^18.0.0", @@ -78,4 +79,4 @@ "react-hook-form": "^7.51.3" }, "clean-package": "../../../clean-package.config.json" -} \ No newline at end of file +} diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 6f84d1c979..ba957d60c4 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -18,6 +18,7 @@ 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"; +import {FormContext, useSlottedContext} from "@nextui-org/form"; import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils"; interface Props extends Omit, keyof ComboBoxProps> { @@ -120,6 +121,7 @@ export type UseAutocompleteProps = Props & export function useAutocomplete(originalProps: UseAutocompleteProps) { const globalContext = useProviderContext(); + const {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; const [props, variantProps] = mapPropsVariants(originalProps, autocomplete.variantKeys); const disableAnimation = @@ -158,7 +160,7 @@ export function useAutocomplete(originalProps: UseAutocomplete clearButtonProps = {}, showScrollIndicators = true, allowsCustomValue = false, - validationBehavior = globalContext?.validationBehavior ?? "aria", + validationBehavior = formValidationBehavior ?? globalContext?.validationBehavior ?? "aria", className, classNames, errorMessage, diff --git a/packages/components/autocomplete/stories/autocomplete.stories.tsx b/packages/components/autocomplete/stories/autocomplete.stories.tsx index ca4db45df2..771d3f5525 100644 --- a/packages/components/autocomplete/stories/autocomplete.stories.tsx +++ b/packages/components/autocomplete/stories/autocomplete.stories.tsx @@ -18,6 +18,7 @@ import {useInfiniteScroll} from "@nextui-org/use-infinite-scroll"; import {PetBoldIcon, SearchLinearIcon, SelectorIcon} from "@nextui-org/shared-icons"; import {Avatar} from "@nextui-org/avatar"; import {Button} from "@nextui-org/button"; +import {Form} from "@nextui-org/form"; import {Autocomplete, AutocompleteItem, AutocompleteProps, AutocompleteSection} from "../src"; @@ -803,6 +804,33 @@ const WithReactHookFormTemplate = (args: AutocompleteProps) => { ); }; +const ServerValidationTemplate = (args: AutocompleteProps) => { + const [serverErrors, setServerErrors] = React.useState({}); + const onSubmit = (e) => { + e.preventDefault(); + setServerErrors({ + animals: "Please select a valid animal.", + }); + }; + + return ( +
+ + Red Panda + Cat + Dog + + +
+ ); +}; + export const Default = { render: Template, args: { @@ -978,6 +1006,13 @@ export const WithValidation = { }, }; +export const WithServerValidation = { + render: ServerValidationTemplate, + args: { + ...defaultProps, + }, +}; + export const WithSections = { render: WithSectionsTemplate, diff --git a/packages/components/avatar/package.json b/packages/components/avatar/package.json index 5d6ab35353..d1c82dd011 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.22.2", - "@react-aria/focus": "3.18.2", - "@react-aria/utils": "3.25.2" + "@react-aria/interactions": "3.22.4", + "@react-aria/focus": "3.18.4", + "@react-aria/utils": "3.25.3" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/breadcrumbs/package.json b/packages/components/breadcrumbs/package.json index 4317644457..ef98db8b2d 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.18.2", - "@react-aria/breadcrumbs": "3.5.16", - "@react-aria/utils": "3.25.2", - "@react-types/breadcrumbs": "3.7.7", - "@react-types/shared": "3.24.1" + "@react-aria/focus": "3.18.4", + "@react-aria/breadcrumbs": "3.5.18", + "@react-aria/utils": "3.25.3", + "@react-types/breadcrumbs": "3.7.8", + "@react-types/shared": "3.25.0" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/button/package.json b/packages/components/button/package.json index 8f93565575..2b5a31e670 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.9.8", - "@react-aria/interactions": "3.22.2", - "@react-aria/utils": "3.25.2", - "@react-aria/focus": "3.18.2", - "@react-types/shared": "3.24.1", - "@react-types/button": "3.9.6" + "@react-aria/button": "3.10.1", + "@react-aria/interactions": "3.22.4", + "@react-aria/utils": "3.25.3", + "@react-aria/focus": "3.18.4", + "@react-types/shared": "3.25.0", + "@react-types/button": "3.10.0" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/calendar/package.json b/packages/components/calendar/package.json index 0272d267f0..39472eca04 100644 --- a/packages/components/calendar/package.json +++ b/packages/components/calendar/package.json @@ -47,18 +47,18 @@ "@nextui-org/use-aria-button": "workspace:*", "@nextui-org/button": "workspace:*", "@nextui-org/dom-animation": "workspace:*", - "@internationalized/date": "3.5.5", - "@react-aria/calendar": "3.5.11", - "@react-aria/focus": "3.18.2", - "@react-aria/i18n": "3.12.2", - "@react-stately/calendar": "3.5.4", - "@react-types/button": "3.9.6", - "@react-aria/visually-hidden": "3.8.15", - "@react-aria/utils": "3.25.2", - "@react-stately/utils": "3.10.3", - "@react-types/calendar": "3.4.9", - "@react-aria/interactions": "3.22.2", - "@react-types/shared": "3.24.1", + "@internationalized/date": "3.5.6", + "@react-aria/calendar": "3.5.13", + "@react-aria/focus": "3.18.4", + "@react-aria/i18n": "3.12.3", + "@react-stately/calendar": "3.5.5", + "@react-types/button": "3.10.0", + "@react-aria/visually-hidden": "3.8.17", + "@react-aria/utils": "3.25.3", + "@react-stately/utils": "3.10.4", + "@react-types/calendar": "3.4.10", + "@react-aria/interactions": "3.22.4", + "@react-types/shared": "3.25.0", "scroll-into-view-if-needed": "3.0.10", "@types/lodash.debounce": "^4.0.7" }, @@ -73,4 +73,4 @@ "react-dom": "^18.0.0" }, "clean-package": "../../../clean-package.config.json" -} \ No newline at end of file +} diff --git a/packages/components/card/package.json b/packages/components/card/package.json index 2059b040da..83a43c12f9 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.18.2", - "@react-aria/utils": "3.25.2", - "@react-aria/interactions": "3.22.2", - "@react-aria/button": "3.9.8", - "@react-types/shared": "3.24.1" + "@react-aria/focus": "3.18.4", + "@react-aria/utils": "3.25.3", + "@react-aria/interactions": "3.22.4", + "@react-aria/button": "3.10.1", + "@react-types/shared": "3.25.0" }, "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 7245ca6b2b..9e604b410b 100644 --- a/packages/components/checkbox/__tests__/checkbox-group.test.tsx +++ b/packages/components/checkbox/__tests__/checkbox-group.test.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import {act, render} from "@testing-library/react"; +import {Form} from "@nextui-org/form"; import userEvent, {UserEvent} from "@testing-library/user-event"; import {CheckboxGroup, Checkbox} from "../src"; @@ -281,6 +282,60 @@ describe("Checkbox.Group", () => { expect(input.validity.valid).toBe(true); } }); + + it("supports server validation", async () => { + function Test() { + const [serverErrors, setServerErrors] = React.useState({}); + const onSubmit = (e) => { + e.preventDefault(); + setServerErrors({ + terms: "You must accept the terms.", + }); + }; + + return ( +
+ + Terms and conditions + Cookies + Privacy policy + + +
+ ); + } + + const {getAllByRole, getByRole} = render(); + + const group = getByRole("group"); + + expect(group).not.toHaveAttribute("aria-describedby"); + const button = getByRole("button"); + + expect(button).toBeVisible(); + await user.click(button); + + expect(group).toHaveAttribute("aria-describedby"); + expect(document.getElementById(group.getAttribute("aria-describedby")!)).toHaveTextContent( + "You must accept the terms.", + ); + + const checkboxes = getAllByRole("checkbox") as HTMLInputElement[]; + + for (let input of checkboxes) { + expect(input.validity.valid).toBe(false); + } + + await user.click(checkboxes[0]); + expect(group).not.toHaveAttribute("aria-describedby"); + for (let input of checkboxes) { + expect(input.validity.valid).toBe(true); + } + }); }); describe("validationBehavior=aria", () => { @@ -320,6 +375,35 @@ describe("Checkbox.Group", () => { await user.click(checkboxes[2]); expect(group).toHaveAttribute("aria-describedby"); }); + + it("supports server validation", async () => { + const {getAllByRole, getByRole} = render( +
+ + Terms and conditions + Cookies + Privacy policy + +
, + ); + + const group = getByRole("group"); + + expect(group).toHaveAttribute("aria-describedby"); + expect(document.getElementById(group.getAttribute("aria-describedby")!)).toHaveTextContent( + "You must accept the terms", + ); + + const checkboxes = getAllByRole("checkbox"); + + for (let input of checkboxes) { + expect(input).toHaveAttribute("aria-invalid", "true"); + } + + // TODO: Fix the following functions to work + // await user.click(checkboxes[0]); + // expect(group).not.toHaveAttribute("aria-describedby"); + }); }); }); }); diff --git a/packages/components/checkbox/package.json b/packages/components/checkbox/package.json index e25432c6a1..43b33194ef 100644 --- a/packages/components/checkbox/package.json +++ b/packages/components/checkbox/package.json @@ -40,19 +40,20 @@ "react-dom": ">=18" }, "dependencies": { + "@nextui-org/form": "workspace:*", "@nextui-org/react-utils": "workspace:*", "@nextui-org/shared-utils": "workspace:*", "@nextui-org/use-callback-ref": "workspace:*", "@nextui-org/use-safe-layout-effect": "workspace:*", - "@react-aria/checkbox": "3.14.6", - "@react-aria/focus": "3.18.2", - "@react-aria/interactions": "3.22.2", - "@react-aria/utils": "3.25.2", - "@react-aria/visually-hidden": "3.8.15", - "@react-stately/checkbox": "3.6.8", - "@react-stately/toggle": "3.7.7", - "@react-types/checkbox": "3.8.3", - "@react-types/shared": "3.24.1" + "@react-aria/checkbox": "3.14.8", + "@react-aria/focus": "3.18.4", + "@react-aria/interactions": "3.22.4", + "@react-aria/utils": "3.25.3", + "@react-aria/visually-hidden": "3.8.17", + "@react-stately/checkbox": "3.6.9", + "@react-stately/toggle": "3.7.8", + "@react-types/checkbox": "3.8.4", + "@react-types/shared": "3.25.0" }, "devDependencies": { "@nextui-org/chip": "workspace:*", @@ -67,4 +68,4 @@ "react-hook-form": "^7.51.3" }, "clean-package": "../../../clean-package.config.json" -} \ No newline at end of file +} diff --git a/packages/components/checkbox/src/checkbox-group.tsx b/packages/components/checkbox/src/checkbox-group.tsx index 5f4aec3dbd..33ca09d4c0 100644 --- a/packages/components/checkbox/src/checkbox-group.tsx +++ b/packages/components/checkbox/src/checkbox-group.tsx @@ -1,5 +1,4 @@ import {forwardRef} from "@nextui-org/system"; -import {useMemo} from "react"; import {CheckboxGroupProvider} from "./checkbox-group-context"; import {UseCheckboxGroupProps, useCheckboxGroup} from "./use-checkbox-group"; @@ -21,16 +20,14 @@ const CheckboxGroup = forwardRef<"div", CheckboxGroupProps>((props, ref) => { getErrorMessageProps, } = useCheckboxGroup({...props, ref}); - const errorMessageContent = useMemo(() => errorMessage, [isInvalid]); - return (
{label && {label}}
{children}
- {isInvalid && errorMessageContent ? ( -
{errorMessageContent}
+ {isInvalid && errorMessage ? ( +
{errorMessage}
) : description ? (
{description}
) : null} diff --git a/packages/components/checkbox/src/use-checkbox-group.ts b/packages/components/checkbox/src/use-checkbox-group.ts index ee7eaba973..f0ca24e562 100644 --- a/packages/components/checkbox/src/use-checkbox-group.ts +++ b/packages/components/checkbox/src/use-checkbox-group.ts @@ -2,7 +2,6 @@ import type {CheckboxGroupSlots, SlotsToClasses} from "@nextui-org/theme"; import type {AriaCheckboxGroupProps} from "@react-types/checkbox"; import type {Orientation} from "@react-types/shared"; import type {ReactRef} from "@nextui-org/react-utils"; -import type {CheckboxGroupProps} from "@react-types/checkbox"; import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system"; import {useProviderContext} from "@nextui-org/system"; @@ -13,6 +12,7 @@ import {useCheckboxGroup as useReactAriaCheckboxGroup} from "@react-aria/checkbo import {CheckboxGroupState, useCheckboxGroupState} from "@react-stately/checkbox"; import {filterDOMProps, useDOMRef} from "@nextui-org/react-utils"; import {clsx, safeAriaLabel} from "@nextui-org/shared-utils"; +import {FormContext, useSlottedContext} from "@nextui-org/form"; import {CheckboxProps} from "./index"; @@ -71,6 +71,7 @@ export type ContextType = { export function useCheckboxGroup(props: UseCheckboxGroupProps) { const globalContext = useProviderContext(); + const {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; const { as, @@ -89,7 +90,7 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { orientation = "vertical", lineThrough = false, isDisabled = false, - validationBehavior = globalContext?.validationBehavior ?? "aria", + validationBehavior = formValidationBehavior ?? globalContext?.validationBehavior ?? "aria", disableAnimation = globalContext?.disableAnimation ?? false, isReadOnly, isRequired, @@ -105,7 +106,7 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { const domRef = useDOMRef(ref); - const checkboxGroupProps = useMemo(() => { + const checkboxGroupProps = useMemo(() => { return { ...otherProps, value, @@ -136,7 +137,6 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { ]); const groupState = useCheckboxGroupState(checkboxGroupProps); - const { labelProps, groupProps, diff --git a/packages/components/checkbox/src/use-checkbox.ts b/packages/components/checkbox/src/use-checkbox.ts index 841b93eab6..f9f4baec20 100644 --- a/packages/components/checkbox/src/use-checkbox.ts +++ b/packages/components/checkbox/src/use-checkbox.ts @@ -18,6 +18,7 @@ import { } from "@react-aria/checkbox"; import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect"; import {mergeRefs} from "@nextui-org/react-utils"; +import {FormContext, useSlottedContext} from "@nextui-org/form"; import {useCheckboxGroupContext} from "./checkbox-group-context"; @@ -75,6 +76,7 @@ export type UseCheckboxProps = Omit & export function useCheckbox(props: UseCheckboxProps = {}) { const globalContext = useProviderContext(); const groupContext = useCheckboxGroupContext(); + const {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; const isInGroup = !!groupContext; const { @@ -97,7 +99,9 @@ export function useCheckbox(props: UseCheckboxProps = {}) { validationState, isInvalid = validationState ? validationState === "invalid" : groupContext?.isInvalid ?? false, isIndeterminate = false, - validationBehavior = groupContext?.validationBehavior ?? "aria", + validationBehavior = isInGroup + ? groupContext.validationBehavior + : formValidationBehavior ?? globalContext?.validationBehavior ?? "aria", defaultSelected, classNames, className, diff --git a/packages/components/checkbox/stories/checkbox-group.stories.tsx b/packages/components/checkbox/stories/checkbox-group.stories.tsx index 34ad88f04b..6182918020 100644 --- a/packages/components/checkbox/stories/checkbox-group.stories.tsx +++ b/packages/components/checkbox/stories/checkbox-group.stories.tsx @@ -4,6 +4,7 @@ import React from "react"; import {Meta} from "@storybook/react"; import {checkbox} from "@nextui-org/theme"; import {button} from "@nextui-org/theme"; +import {Form} from "@nextui-org/form"; import {CheckboxGroup, Checkbox, CheckboxGroupProps} from "../src"; @@ -90,7 +91,7 @@ const InvalidTemplate = (args: CheckboxGroupProps) => { const FormTemplate = (args: CheckboxGroupProps) => { return (
{ const formData = new FormData(e.currentTarget); const selectedCities = formData.getAll("favorite-cities"); @@ -135,6 +136,40 @@ const ControlledTemplate = (args: CheckboxGroupProps) => { ); }; +const ServerValidationTemplate = (args: CheckboxGroupProps) => { + const [serverErrors, setServerErrors] = React.useState({}); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + setServerErrors({ + terms: "Please select a valid animal.", + }); + }; + + return ( + + + Terms and conditions + Cookies + Privacy policy + + + + ); +}; + export const Default = { render: Template, @@ -253,6 +288,14 @@ export const WithValidation = { }, }; +export const WithServerValidation = { + render: ServerValidationTemplate, + + args: { + ...defaultProps, + }, +}; + export const DisableAnimation = { render: Template, diff --git a/packages/components/checkbox/stories/checkbox.stories.tsx b/packages/components/checkbox/stories/checkbox.stories.tsx index aa60c33c0d..f4982f19d1 100644 --- a/packages/components/checkbox/stories/checkbox.stories.tsx +++ b/packages/components/checkbox/stories/checkbox.stories.tsx @@ -75,7 +75,7 @@ const ControlledTemplate = (args: CheckboxProps) => { const FormTemplate = (args: CheckboxProps) => { return (
{ e.preventDefault(); const checkbox = e.target["check"] as HTMLInputElement; diff --git a/packages/components/chip/package.json b/packages/components/chip/package.json index ff778d80bf..b65ab1d601 100644 --- a/packages/components/chip/package.json +++ b/packages/components/chip/package.json @@ -43,10 +43,10 @@ "@nextui-org/shared-icons": "workspace:*", "@nextui-org/shared-utils": "workspace:*", "@nextui-org/react-utils": "workspace:*", - "@react-aria/focus": "3.18.2", - "@react-aria/interactions": "3.22.2", - "@react-aria/utils": "3.25.2", - "@react-types/checkbox": "3.8.3" + "@react-aria/focus": "3.18.4", + "@react-aria/interactions": "3.22.4", + "@react-aria/utils": "3.25.3", + "@react-types/checkbox": "3.8.4" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/date-input/package.json b/packages/components/date-input/package.json index a306aa8f57..6850375d2a 100644 --- a/packages/components/date-input/package.json +++ b/packages/components/date-input/package.json @@ -40,15 +40,16 @@ "react-dom": ">=18" }, "dependencies": { + "@nextui-org/form": "workspace:*", "@nextui-org/react-utils": "workspace:*", "@nextui-org/shared-utils": "workspace:*", - "@internationalized/date": "3.5.5", - "@react-aria/datepicker": "3.11.2", - "@react-aria/i18n": "3.12.2", - "@react-stately/datepicker": "3.10.2", - "@react-types/datepicker": "3.8.2", - "@react-types/shared": "3.24.1", - "@react-aria/utils": "3.25.2" + "@internationalized/date": "3.5.6", + "@react-aria/datepicker": "3.11.4", + "@react-aria/i18n": "3.12.3", + "@react-stately/datepicker": "3.10.3", + "@react-types/datepicker": "3.8.3", + "@react-types/shared": "3.25.0", + "@react-aria/utils": "3.25.3" }, "devDependencies": { "@nextui-org/system": "workspace:*", @@ -60,4 +61,4 @@ "react-dom": "^18.0.0" }, "clean-package": "../../../clean-package.config.json" -} \ No newline at end of file +} diff --git a/packages/components/date-input/src/use-date-input.ts b/packages/components/date-input/src/use-date-input.ts index a13f64022f..0f88927714 100644 --- a/packages/components/date-input/src/use-date-input.ts +++ b/packages/components/date-input/src/use-date-input.ts @@ -18,6 +18,7 @@ import {useDateFieldState} from "@react-stately/datepicker"; import {objectToDeps, clsx, dataAttr, getGregorianYearOffset} from "@nextui-org/shared-utils"; import {dateInput, cn} from "@nextui-org/theme"; import {useMemo} from "react"; +import {FormContext, useSlottedContext} from "@nextui-org/form"; type NextUIBaseProps = Omit< HTMLNextUIProps<"div">, @@ -115,6 +116,7 @@ export type UseDateInputProps = Props & export function useDateInput(originalProps: UseDateInputProps) { const globalContext = useProviderContext(); + const {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; const [props, variantProps] = mapPropsVariants(originalProps, dateInput.variantKeys); @@ -144,7 +146,7 @@ export function useDateInput(originalProps: UseDateInputPro innerWrapperProps: innerWrapperPropsProp, errorMessageProps: errorMessagePropsProp, descriptionProps: descriptionPropsProp, - validationBehavior = globalContext?.validationBehavior ?? "aria", + validationBehavior = formValidationBehavior ?? globalContext?.validationBehavior ?? "aria", shouldForceLeadingZeros = true, minValue = globalContext?.defaultDates?.minDate ?? new CalendarDate(calendarProp, 1900 + gregorianYearOffset, 1, 1), diff --git a/packages/components/date-input/src/use-time-input.ts b/packages/components/date-input/src/use-time-input.ts index 042ae11589..7f41ae425f 100644 --- a/packages/components/date-input/src/use-time-input.ts +++ b/packages/components/date-input/src/use-time-input.ts @@ -14,6 +14,7 @@ import {useTimeFieldState} from "@react-stately/datepicker"; import {objectToDeps, clsx, dataAttr} from "@nextui-org/shared-utils"; import {dateInput} from "@nextui-org/theme"; import {useMemo} from "react"; +import {FormContext, useSlottedContext} from "@nextui-org/form"; type NextUIBaseProps = Omit< HTMLNextUIProps<"div">, @@ -74,6 +75,7 @@ export type UseTimeInputProps = Props & export function useTimeInput(originalProps: UseTimeInputProps) { const globalContext = useProviderContext(); + const {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; const [props, variantProps] = mapPropsVariants(originalProps, dateInput.variantKeys); @@ -92,7 +94,7 @@ export function useTimeInput(originalProps: UseTimeInputPro fieldProps: fieldPropsProp, errorMessageProps: errorMessagePropsProp, descriptionProps: descriptionPropsProp, - validationBehavior = globalContext?.validationBehavior ?? "aria", + validationBehavior = formValidationBehavior ?? globalContext?.validationBehavior ?? "aria", shouldForceLeadingZeros = true, minValue, maxValue, diff --git a/packages/components/date-picker/__tests__/date-picker.test.tsx b/packages/components/date-picker/__tests__/date-picker.test.tsx index eeb9e5bf33..461aa8be3f 100644 --- a/packages/components/date-picker/__tests__/date-picker.test.tsx +++ b/packages/components/date-picker/__tests__/date-picker.test.tsx @@ -1,10 +1,11 @@ /* eslint-disable jsx-a11y/no-autofocus */ import * as React from "react"; -import {render, act, fireEvent, waitFor} from "@testing-library/react"; +import {render, act, fireEvent, waitFor, within} from "@testing-library/react"; import {pointerMap, triggerPress} from "@nextui-org/test-utils"; import userEvent from "@testing-library/user-event"; import {CalendarDate, CalendarDateTime} from "@internationalized/date"; import {NextUIProvider} from "@nextui-org/system"; +import {Form} from "@nextui-org/form"; import {DatePicker as DatePickerBase, DatePickerProps} from "../src"; @@ -232,7 +233,7 @@ describe("DatePicker", () => { }); it("should apply custom dateInput classNames", function () { - const {getByRole, getByText} = render( + const {getByText} = render( { expect(label).toHaveClass("text-green-500"); - const inputWrapper = getByRole("group"); + const inputWrapper = document.querySelector( + `div[data-slot="input-wrapper"]`, + )!; expect(inputWrapper).toHaveClass("border-green-500"); }); @@ -739,4 +742,215 @@ describe("DatePicker", () => { expect(dialog).not.toBeVisible(); }); }); + + describe("validation", () => { + describe("validationBehavior=native", () => { + it("supports isRequired", async () => { + const {getByRole, getByTestId} = render( + + + , + ); + + const group = getByRole("group"); + const input = document.querySelector("input[name=date]") as HTMLInputElement; + + expect(input).toHaveAttribute("required"); + expect(input.validity.valid).toBe(false); + expect(group).not.toHaveAttribute("aria-describedby"); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(group).toHaveAttribute("aria-describedby"); + const getDescription = () => + group + .getAttribute("aria-describedby") + ?.split(" ") + .map((d) => document.getElementById(d)?.textContent) + .join(" "); + + expect(getDescription()).toContain("Constraints not satisfied"); + expect(document.activeElement).toBe(within(group).getAllByRole("spinbutton")[0]); + + await user.keyboard("[ArrowUp][Tab][ArrowUp][Tab][ArrowUp]"); + + expect(getDescription()).toContain("Constraints not satisfied"); + expect(input.validity.valid).toBe(true); + }); + }); + + it("supports validate function", async () => { + const {getByRole, getByTestId} = render( +
+ (v.year < 2022 ? "Invalid value" : null)} + validationBehavior="native" + /> + , + ); + + const group = getByRole("group"); + const input = document.querySelector("input[name=date]") as HTMLInputElement; + const getDescription = () => + group + .getAttribute("aria-describedby") + ?.split(" ") + .map((d) => document.getElementById(d)?.textContent) + .join(" "); + + expect(getDescription()).not.toContain("Invalid value"); + expect(input.validity.valid).toBe(false); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(group).toHaveAttribute("aria-describedby"); + expect(getDescription()).toContain("Invalid value"); + expect(document.activeElement).toBe(within(group).getAllByRole("spinbutton")[0]); + + await user.keyboard("[ArrowRight][ArrowRight]2024"); + + expect(getDescription()).toContain("Invalid value"); + expect(input.validity.valid).toBe(true); + }); + + it("supports server validation", async () => { + function Test() { + const [serverErrors, setServerErrors] = React.useState({}); + const onSubmit = (e) => { + e.preventDefault(); + setServerErrors({ + date: "Invalid value", + }); + }; + + return ( +
+ + + + ); + } + + const {getByTestId, getByRole} = render(); + + const group = getByRole("group"); + const input = document.querySelector("input[name=date]") as HTMLInputElement; + + expect(group).not.toHaveAttribute("aria-describedby"); + + await user.click(getByTestId("submit")); + + const getDescription = () => + group + .getAttribute("aria-describedby") + ?.split(" ") + .map((d) => document.getElementById(d)?.textContent) + .join(" "); + + expect(getDescription()).toContain("Invalid value"); + expect(input.validity.valid).toBe(false); + + await user.tab({shift: true}); + await user.tab({shift: true}); + await user.keyboard("2024[ArrowLeft]2[ArrowLeft]2"); + act(() => (document.activeElement as HTMLInputElement)?.blur()); + + expect(getDescription()).not.toContain("Invalid value"); + expect(input.validity.valid).toBe(true); + }); + + describe("validationBehavior=aria", () => { + it("supports minValue and maxValue", async () => { + const {getByRole} = render( +
+ + , + ); + + const group = getByRole("group"); + const getDescription = () => + group + .getAttribute("aria-describedby") + ?.split(" ") + .map((d) => document.getElementById(d)?.textContent) + .join(" "); + + expect(getDescription()).toContain("Value must be 2/3/2020 or later."); + + await user.keyboard("[Tab][Tab][Tab][ArrowUp]"); + expect(getDescription()).not.toContain("Value must be 2/3/2020 or later."); + + await user.keyboard("[ArrowUp][ArrowUp][ArrowUp][ArrowUp][ArrowUp]"); + expect(getDescription()).toContain("Value must be 2/3/2024 or earlier."); + + await user.keyboard("[ArrowDown]"); + expect(getDescription()).not.toContain("Value must be 2/3/2024 or earlier."); + }); + + it("supports validate function", async () => { + const {getByRole} = render( +
+ (v.year < 2022 ? "Invalid value" : null)} + /> + , + ); + + const group = getByRole("group"); + + expect(group).toHaveAttribute("aria-describedby"); + const getDescription = () => + group + .getAttribute("aria-describedby") + ?.split(" ") + .map((d) => document.getElementById(d)?.textContent) + .join(" "); + + expect(getDescription()).toContain("Invalid value"); + + await user.keyboard("[Tab][ArrowRight][ArrowRight]2024"); + expect(getDescription()).not.toContain("Invalid value"); + }); + + it("supports server validation", async () => { + const {getByRole} = render( +
+ + , + ); + + const group = getByRole("group"); + + expect(group).toHaveAttribute("aria-describedby"); + const getDescription = () => + group + .getAttribute("aria-describedby") + ?.split(" ") + .map((d) => document.getElementById(d)?.textContent) + .join(" "); + + expect(getDescription()).toContain("Invalid value"); + + await user.keyboard("[Tab][ArrowRight][ArrowRight]2024[Tab]"); + expect(getDescription()).not.toContain("Invalid value"); + }); + }); + }); }); diff --git a/packages/components/date-picker/package.json b/packages/components/date-picker/package.json index a6bfd36072..d63a79ff67 100644 --- a/packages/components/date-picker/package.json +++ b/packages/components/date-picker/package.json @@ -40,23 +40,24 @@ "react-dom": ">=18" }, "dependencies": { - "@internationalized/date": "3.5.5", + "@internationalized/date": "3.5.6", "@nextui-org/aria-utils": "workspace:*", "@nextui-org/button": "workspace:*", "@nextui-org/calendar": "workspace:*", "@nextui-org/date-input": "workspace:*", + "@nextui-org/form": "workspace:*", "@nextui-org/popover": "workspace:*", "@nextui-org/react-utils": "workspace:*", "@nextui-org/shared-icons": "workspace:*", "@nextui-org/shared-utils": "workspace:*", - "@react-aria/datepicker": "3.11.2", - "@react-aria/i18n": "3.12.2", - "@react-aria/utils": "3.25.2", - "@react-stately/datepicker": "3.10.2", - "@react-stately/overlays": "3.6.10", - "@react-stately/utils": "3.10.3", - "@react-types/datepicker": "3.8.2", - "@react-types/shared": "3.24.1" + "@react-aria/datepicker": "3.11.4", + "@react-aria/i18n": "3.12.3", + "@react-aria/utils": "3.25.3", + "@react-stately/datepicker": "3.10.3", + "@react-stately/overlays": "3.6.11", + "@react-stately/utils": "3.10.4", + "@react-types/datepicker": "3.8.3", + "@react-types/shared": "3.25.0" }, "devDependencies": { "@nextui-org/radio": "workspace:*", diff --git a/packages/components/date-picker/src/use-date-picker.ts b/packages/components/date-picker/src/use-date-picker.ts index 645059e6d7..25b563e82f 100644 --- a/packages/components/date-picker/src/use-date-picker.ts +++ b/packages/components/date-picker/src/use-date-picker.ts @@ -15,6 +15,7 @@ import {useDatePickerState} from "@react-stately/datepicker"; import {AriaDatePickerProps, useDatePicker as useAriaDatePicker} from "@react-aria/datepicker"; import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils"; import {mergeProps} from "@react-aria/utils"; +import {FormContext, useSlottedContext} from "@nextui-org/form"; import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils"; import {useDatePickerBase} from "./use-date-picker-base"; @@ -63,9 +64,13 @@ export function useDatePicker({ ...originalProps }: UseDatePickerProps) { const globalContext = useProviderContext(); + const {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; const validationBehavior = - originalProps.validationBehavior ?? globalContext?.validationBehavior ?? "aria"; + originalProps.validationBehavior ?? + formValidationBehavior ?? + globalContext?.validationBehavior ?? + "aria"; const { domRef, diff --git a/packages/components/date-picker/src/use-date-range-picker.ts b/packages/components/date-picker/src/use-date-range-picker.ts index 89bfbe2e19..df9134c283 100644 --- a/packages/components/date-picker/src/use-date-range-picker.ts +++ b/packages/components/date-picker/src/use-date-range-picker.ts @@ -21,6 +21,7 @@ import {useDateRangePicker as useAriaDateRangePicker} from "@react-aria/datepick import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils"; import {mergeProps} from "@react-aria/utils"; import {dateRangePicker, dateInput, cn} from "@nextui-org/theme"; +import {FormContext, useSlottedContext} from "@nextui-org/form"; import {ariaShouldCloseOnInteractOutside} from "@nextui-org/aria-utils"; import {useDatePickerBase} from "./use-date-picker-base"; @@ -71,8 +72,12 @@ export function useDateRangePicker({ }: UseDateRangePickerProps) { const globalContext = useProviderContext(); + const {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; const validationBehavior = - originalProps.validationBehavior ?? globalContext?.validationBehavior ?? "aria"; + originalProps.validationBehavior ?? + formValidationBehavior ?? + globalContext?.validationBehavior ?? + "aria"; const { domRef, diff --git a/packages/components/date-picker/stories/date-picker.stories.tsx b/packages/components/date-picker/stories/date-picker.stories.tsx index e49afe48db..49a72bb301 100644 --- a/packages/components/date-picker/stories/date-picker.stories.tsx +++ b/packages/components/date-picker/stories/date-picker.stories.tsx @@ -21,6 +21,7 @@ import {Radio, RadioGroup, RadioProps} from "@nextui-org/radio"; import {cn} from "@nextui-org/theme"; import {MoonIcon, SunIcon} from "@nextui-org/shared-icons"; import {ValidationResult} from "@react-types/shared"; +import {Form} from "@nextui-org/form"; import {DatePicker, DatePickerProps} from "../src"; @@ -315,6 +316,29 @@ const UnavailableDatesTemplate = (args: DatePickerProps) => { ); }; +const ServerValidationTemplate = (args: DatePickerProps) => { + const [serverErrors, setServerErrors] = React.useState({}); + const onSubmit = (e) => { + e.preventDefault(); + setServerErrors({ + date: "Please select a valid date.", + }); + }; + + return ( +
+ + + + ); +}; + const StartAndEndContentTemplate = (args: DatePickerProps) => { return (
@@ -630,6 +654,13 @@ export const WithValidation = { }, }; +export const WithServerValidation = { + render: ServerValidationTemplate, + args: { + ...defaultProps, + }, +}; + export const WithStartAndEndContent = { render: StartAndEndContentTemplate, args: { diff --git a/packages/components/date-picker/stories/date-range-picker.stories.tsx b/packages/components/date-picker/stories/date-range-picker.stories.tsx index a54f9cf7cb..1dcac5ac4d 100644 --- a/packages/components/date-picker/stories/date-range-picker.stories.tsx +++ b/packages/components/date-picker/stories/date-range-picker.stories.tsx @@ -21,6 +21,7 @@ import {I18nProvider, useDateFormatter, useLocale} from "@react-aria/i18n"; import {Button, ButtonGroup} from "@nextui-org/button"; import {Radio, RadioGroup, RadioProps} from "@nextui-org/radio"; import {cn} from "@nextui-org/theme"; +import {Form} from "@nextui-org/form"; import {MoonIcon, SunIcon} from "@nextui-org/shared-icons"; import {DateRangePicker, DateRangePickerProps} from "../src"; @@ -379,6 +380,30 @@ const PresetsTemplate = (args: DateRangePickerProps) => { ); }; +const ServerValidationTemplate = (args: DateRangePickerProps) => { + const [serverErrors, setServerErrors] = React.useState({}); + const onSubmit = (e) => { + e.preventDefault(); + setServerErrors({ + startDate: "Please select a valid start date.", + endDate: "Please select a valid end date.", + }); + }; + + return ( +
+ + + + ); +}; + const StartAndEndContentTemplate = (args: DateRangePickerProps) => { return (
@@ -719,6 +744,13 @@ export const WithValidation = { }, }; +export const WithServerValidation = { + render: ServerValidationTemplate, + args: { + ...defaultProps, + }, +}; + export const WithStartAndEndContent = { render: StartAndEndContentTemplate, args: { diff --git a/packages/components/divider/package.json b/packages/components/divider/package.json index 2cc01f0547..1f15651135 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.24.1" + "@react-types/shared": "3.25.0" }, "devDependencies": { "@nextui-org/theme": "workspace:*", diff --git a/packages/components/dropdown/__tests__/dropdown.test.tsx b/packages/components/dropdown/__tests__/dropdown.test.tsx index 86bf926aaa..370d5ca718 100644 --- a/packages/components/dropdown/__tests__/dropdown.test.tsx +++ b/packages/components/dropdown/__tests__/dropdown.test.tsx @@ -560,14 +560,12 @@ describe("Dropdown", () => { ); const dropdown = wrapper.getByTestId("dropdown"); - const dropdown2 = wrapper.getByTestId("dropdown2"); - expect(dropdown).not.toBeNull(); - - expect(dropdown2).not.toBeNull(); + expect(dropdown).toBeVisible(); + expect(dropdown2).toBeVisible(); - // open the dropdown listbox by clicking dropdownor button in the first dropdown + // open the dropdown listbox by clicking dropdown button in the first dropdown await user.click(dropdown); // assert that the first dropdown listbox is open diff --git a/packages/components/dropdown/package.json b/packages/components/dropdown/package.json index 27c91e72ab..15a594795d 100644 --- a/packages/components/dropdown/package.json +++ b/packages/components/dropdown/package.json @@ -46,11 +46,11 @@ "@nextui-org/popover": "workspace:*", "@nextui-org/react-utils": "workspace:*", "@nextui-org/shared-utils": "workspace:*", - "@react-aria/focus": "3.18.2", - "@react-aria/menu": "3.15.3", - "@react-aria/utils": "3.25.2", - "@react-stately/menu": "3.8.2", - "@react-types/menu": "3.9.11" + "@react-aria/focus": "3.18.4", + "@react-aria/menu": "3.15.5", + "@react-aria/utils": "3.25.3", + "@react-stately/menu": "3.8.3", + "@react-types/menu": "3.9.12" }, "devDependencies": { "@nextui-org/avatar": "workspace:*", @@ -67,4 +67,4 @@ "react-dom": "^18.0.0" }, "clean-package": "../../../clean-package.config.json" -} \ No newline at end of file +} diff --git a/packages/components/dropdown/src/use-dropdown.ts b/packages/components/dropdown/src/use-dropdown.ts index cff1cc1b9e..fa78241ab3 100644 --- a/packages/components/dropdown/src/use-dropdown.ts +++ b/packages/components/dropdown/src/use-dropdown.ts @@ -77,7 +77,7 @@ const getCloseOnSelect = ( return props?.closeOnSelect; }; -export function useDropdown(props: UseDropdownProps) { +export function useDropdown(props: UseDropdownProps): UseDropdownReturn { const globalContext = useProviderContext(); const { @@ -212,4 +212,18 @@ export function useDropdown(props: UseDropdownProps) { }; } -export type UseDropdownReturn = ReturnType; +// export type UseDropdownReturn = ReturnType; + +export type UseDropdownReturn = { + Component: string | React.ElementType; + menuRef: React.RefObject; + menuProps: any; + classNames: string; + closeOnSelect: boolean; + onClose: () => void; + autoFocus: any; + disableAnimation: boolean; + getPopoverProps: PropGetter; + getMenuProps: (props?: Partial>, ref?: Ref) => MenuProps; + getMenuTriggerProps: (props?: any, ref?: Ref) => any; +}; diff --git a/packages/components/form/README.md b/packages/components/form/README.md new file mode 100644 index 0000000000..ed42241ec3 --- /dev/null +++ b/packages/components/form/README.md @@ -0,0 +1,24 @@ +# @nextui-org/form + +A Quick description of the component + +> This is an internal utility, not intended for public usage. + +## Installation + +```sh +yarn add @nextui-org/form +# or +npm i @nextui-org/form +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/nextui-org/nextui/blob/master/CONTRIBUTING.md) +for details. + +## License + +This project is licensed under the terms of the +[MIT license](https://github.com/nextui-org/nextui/blob/master/LICENSE). diff --git a/packages/components/form/package.json b/packages/components/form/package.json new file mode 100644 index 0000000000..99a8d093f8 --- /dev/null +++ b/packages/components/form/package.json @@ -0,0 +1,54 @@ +{ + "name": "@nextui-org/form", + "version": "2.0.0", + "description": "A form is a group of inputs that allows users submit data to a server and supports field validation errors.", + "keywords": [ + "form" + ], + "author": "Junior Garcia ", + "homepage": "https://nextui.org", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nextui-org/nextui.git", + "directory": "packages/components/form" + }, + "bugs": { + "url": "https://github.com/nextui-org/nextui/issues" + }, + "scripts": { + "build": "tsup src --dts", + "build:fast": "tsup src", + "dev": "pnpm build:fast --watch", + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "peerDependencies": { + "@nextui-org/system": ">=2.0.0", + "@nextui-org/theme": ">=2.0.0", + "react": ">=18", + "react-dom": ">=18" + }, + "dependencies": { + "@nextui-org/react-utils": "workspace:*", + "@nextui-org/shared-utils": "workspace:*", + "react-aria-components": "^1.2.1" + }, + "devDependencies": { + "@nextui-org/button": "workspace:*", + "clean-package": "2.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "clean-package": "../../../clean-package.config.json" +} diff --git a/packages/components/form/src/form.tsx b/packages/components/form/src/form.tsx new file mode 100644 index 0000000000..a878f43953 --- /dev/null +++ b/packages/components/form/src/form.tsx @@ -0,0 +1,10 @@ +import {Form as AriaForm, FormProps} from "react-aria-components"; +import {useProviderContext} from "@nextui-org/system"; + +export const Form = (props: FormProps) => { + const globalContext = useProviderContext(); + const validationBehavior = + props.validationBehavior ?? globalContext?.validationBehavior ?? "aria"; + + return ; +}; diff --git a/packages/components/form/src/index.ts b/packages/components/form/src/index.ts new file mode 100644 index 0000000000..803c679183 --- /dev/null +++ b/packages/components/form/src/index.ts @@ -0,0 +1,11 @@ +// export types +export type {FormProps} from "react-aria-components"; + +// export hooks +export {useSlottedContext} from "react-aria-components"; + +// export context +export {FormContext} from "react-aria-components"; + +// export component +export {Form} from "./form"; diff --git a/packages/components/form/stories/form.stories.tsx b/packages/components/form/stories/form.stories.tsx new file mode 100644 index 0000000000..c9eb3d4526 --- /dev/null +++ b/packages/components/form/stories/form.stories.tsx @@ -0,0 +1,76 @@ +import React, {useState} from "react"; +import {Meta} from "@storybook/react"; +// import {Input} from "@nextui-org/input"; +import {Button} from "@nextui-org/button"; + +import {Form, FormProps} from "../src"; + +export default { + title: "Components/Form", + component: Form, + argTypes: { + isDisabled: { + control: { + type: "boolean", + }, + }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, + }, +} as Meta; + +const defaultProps = {}; + +const Template = (args: FormProps) => ( +
+ {/* TODO: Doesn't work due to circular dependencies in the monorepo. + * See: https://github.com/vercel/turborepo/discussions/1752 + */} + {/* */} + +
+); + +export const Default = { + render: Template, + args: { + ...defaultProps, + }, +}; + +export const NativeValidation = { + render: Template, + args: { + ...defaultProps, + validationBehavior: "native", + }, +}; + +export const AriaValidation = { + render: Template, + args: { + ...defaultProps, + validationBehavior: "aria", + }, +}; + +export const ServerValidation = () => { + const [serverErrors, setServerErrors] = useState({}); + const onSubmit = async (e) => { + e.preventDefault(); + let errors = {}; + + for (let el of e.target.elements) { + errors[el.name] = `Invalid value for "${el.name}".`; + } + setServerErrors(errors); + }; + + return