diff --git a/.changeset/smooth-laws-tap.md b/.changeset/smooth-laws-tap.md new file mode 100644 index 0000000000..819c0ae346 --- /dev/null +++ b/.changeset/smooth-laws-tap.md @@ -0,0 +1,13 @@ +--- +"@nextui-org/autocomplete": minor +"@nextui-org/checkbox": minor +"@nextui-org/date-input": minor +"@nextui-org/date-picker": minor +"@nextui-org/input": minor +"@nextui-org/radio": minor +"@nextui-org/select": minor +"@nextui-org/system": minor +"@nextui-org/use-aria-multiselect": minor +--- + +Change validationBehavior from native to aria by default, with the option to change via props. diff --git a/apps/docs/content/docs/api-references/nextui-provider.mdx b/apps/docs/content/docs/api-references/nextui-provider.mdx index 0a5b7f55ae..dcc6abb52f 100644 --- a/apps/docs/content/docs/api-references/nextui-provider.mdx +++ b/apps/docs/content/docs/api-references/nextui-provider.mdx @@ -158,6 +158,15 @@ interface AppProviderProps { - **Type**: `boolean` - **Default**: Same as `disableAnimation` + + +`validationBehavior` + +- **Description**: Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, +or mark the field as required or invalid via ARIA. +- **Type**: `native | aria` +- **Default**: `aria` + --- ## Types diff --git a/apps/docs/content/docs/components/autocomplete.mdx b/apps/docs/content/docs/components/autocomplete.mdx index dfa32c8c73..6b127b7fbc 100644 --- a/apps/docs/content/docs/components/autocomplete.mdx +++ b/apps/docs/content/docs/components/autocomplete.mdx @@ -428,6 +428,7 @@ properties to customize the popover, listbox and input components. | disabledKeys | `all` \| `React.Key[]` | The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with. | - | | 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. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA.| `aria` | | 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/calendar.mdx b/apps/docs/content/docs/components/calendar.mdx index b4a48c743a..fab85ee1f4 100644 --- a/apps/docs/content/docs/components/calendar.mdx +++ b/apps/docs/content/docs/components/calendar.mdx @@ -226,10 +226,9 @@ Here's the example to customize `topContent` and `bottomContent` to have some pr | isDateUnavailable | `(date: DateValue) => boolean` | Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. | - | | createCalendar | `(calendar: SupportedCalendars) => Calendar \| null` | This function helps to reduce the bundle size by providing a custom calendar system. You can also use the NextUIProvider to provide the createCalendar function to all nested components. | `all
calendars` | | errorMessage | `ReactNode \| (v: ValidationResult) => ReactNode` | An error message for the field. | - | -| validate | `(value: { inputValue: string, selectedKey: React.Key }) => ValidationError | true | null | undefined` | Validate time input values when committing (e.g. on blur), and return error messages for invalid values. | - | | hideDisabledDates | `boolean` | Whether to hide the disabled or invalid dates. | `false` | | disableAnimation | `boolean` | Whether to disable the animation of the calendar. | `false` | -| classNames | `Record<"base"| "prevButton"| "nextButton"| "headerWrapper" \| "header" \| "title" \| "content" \| "gridWrapper" \| "grid" \| "gridHeader" \| "gridHeaderRow" \| "gridHeaderCell" \| "gridBody" \| "gridBodyRow" \| "cell" \| "cellButton" \| "pickerWrapper" \| "pickerMonthList" \| "pickerYearList" \| "pickerHighlight" \| "pickerItem" \| "helperWrapper" \| "errorMessage", string>` | Allows to set custom class names for the calendar slots. | - | +| classNames | `Record<"base"| "prevButton"| "nextButton"| "headerWrapper" \| "header" \| "title" \| "content" \| "gridWrapper" \| "grid" \| "gridHeader" \| "gridHeaderRow" \| "gridHeaderCell" \| "gridBody" \| "gridBodyRow" \| "cell" \| "cellButton" \| "pickerWrapper" \| "pickerMonthList" \| "pickerYearList" \| "pickerHighlight" \| "pickerItem" \| "helperWrapper" \| "errorMessage", string>` | Allows to set custom class names for the calendar slots. | - | ### Calendar Events diff --git a/apps/docs/content/docs/components/checkbox-group.mdx b/apps/docs/content/docs/components/checkbox-group.mdx index ffabd48256..ac1f1927a2 100644 --- a/apps/docs/content/docs/components/checkbox-group.mdx +++ b/apps/docs/content/docs/components/checkbox-group.mdx @@ -93,28 +93,29 @@ In case you need to customize the checkbox even further, you can use the `useChe ### Checkbox Group Props -| Attribute | Type | Description | Default | -| ---------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------- | -| children | `ReactNode[]` \| `ReactNode[]` | The checkboxes items. | - | -| orientation | `vertical` \| `horizontal` | The axis the checkbox group items should align with. | `vertical` | -| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the checkboxes. | `primary` | -| 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. | - | -| 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` \| `((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. | - | -| disableAnimation | `boolean` | Whether the animation should be disabled. | `false` | -| classNames | `Record<"base"| "wrapper"| "label", string>` | Allows to set custom class names for the checkbox group slots. | - | +| Attribute | Type | Description | Default | +| ------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| children | `ReactNode[]` \| `ReactNode[]` | The checkboxes items. | - | +| orientation | `vertical` \| `horizontal` | The axis the checkbox group items should align with. | `vertical` | +| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the checkboxes. | `primary` | +| 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. | - | +| 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` \| `((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. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA.| `aria` | +| 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. | - | +| disableAnimation | `boolean` | Whether the animation should be disabled. | `false` | +| classNames | `Record<"base"| "wrapper"| "label", string>` | Allows to set custom class names for the checkbox group slots. | - | ### Checkbox Group Events diff --git a/apps/docs/content/docs/components/date-input.mdx b/apps/docs/content/docs/components/date-input.mdx index 95929a0f2c..0c0f6d2452 100644 --- a/apps/docs/content/docs/components/date-input.mdx +++ b/apps/docs/content/docs/components/date-input.mdx @@ -284,39 +284,40 @@ import {parseZonedDateTime} from "@internationalized/date"; ### DateInput Props -| Attribute | Type | Description | Default | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------- | -| label | `ReactNode` | The content to display as the label. | - | -| value | `DateValue` | The current value of the date input (controlled). | - | -| defaultValue | `DateValue` | The default value of the date input (uncontrolled). | - | -| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the date input. | `flat` | -| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the date input. | `default` | -| size | `sm` \| `md` \| `lg` | The size of the date input. | `md` | -| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the date input. | - | -| placeholderValue | `DateValue` | A placeholder time that influences the format of the placeholder shown when no value is selected. Defaults current date at midnight. | - | -| minValue | `DateValue` | The minimum allowed date that a user may select. | - | -| maxValue | `DateValue` | The maximum allowed date that a user may select. | - | -| locale | `string` | The locale to display and edit the value according to. | - | -| description | `ReactNode` | A description for the date input. Provides a hint such as specific requirements for what to choose. | - | -| errorMessage | `ReactNode \| (v: ValidationResult) => ReactNode` | An error message for the date input. | - | -| startContent | `ReactNode` | Element to be rendered in the left side of the date input. | - | -| endContent | `ReactNode` | Element to be rendered in the right side of the date input. | - | -| labelPlacement | `inside` \| `outside` \| `outside-left` | The position of the label. | `inside` | -| isRequired | `boolean` | Whether user input is required on the input before form submission. | `false` | -| isReadOnly | `boolean` | Whether the input can be selected but not changed by the user. | - | -| isDisabled | `boolean` | Whether the input is disabled. | `false` | -| isInvalid | `boolean` | Whether the input value is invalid. | `false` | -| inputRef | `ReactRef` | A ref for the hidden input element for HTML form submission. | - | -| 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. | - | -| createCalendar | `(name: string) => Calendar` | A function that creates a Calendar object for a given calendar identifier. | - | -| isDateUnavailable | `(date: DateValue) => boolean` | Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. | - | -| autoFocus | `boolean` | Whether the element should receive focus on render. | `false` | -| hourCycle | `12` \| `24` | Whether to display the time in 12 or 24 hour format. This is determined by the user's locale. | - | -| granularity | `day` \| `hour` \| `minute` \| `second` | Determines the smallest unit that is displayed in the date picker. Typically "day" for dates. | - | -| hideTimeZone | `boolean` | Whether to hide the time zone abbreviation. | `false` | -| shouldForceLeadingZeros | `boolean` | Whether to always show leading zeros in the month, day, and hour fields. | `true` | -| disableAnimation | `boolean` | Whether to disable animations. | `false` | -| classNames | `Record<"base"| "label"| "inputWrapper"| "innerWrapper"| "input"| "helperWrapper"| "description"| "errorMessage", string>` | Allows to set custom class names for the date input slots. | - | +| Attribute | Type | Description | Default | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| label | `ReactNode` | The content to display as the label. | - | +| value | `DateValue` | The current value of the date input (controlled). | - | +| defaultValue | `DateValue` | The default value of the date input (uncontrolled). | - | +| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the date input. | `flat` | +| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the date input. | `default` | +| size | `sm` \| `md` \| `lg` | The size of the date input. | `md` | +| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the date input. | - | +| placeholderValue | `DateValue` | A placeholder time that influences the format of the placeholder shown when no value is selected. Defaults current date at midnight. | - | +| minValue | `DateValue` | The minimum allowed date that a user may select. | - | +| maxValue | `DateValue` | The maximum allowed date that a user may select. | - | +| locale | `string` | The locale to display and edit the value according to. | - | +| description | `ReactNode` | A description for the date input. Provides a hint such as specific requirements for what to choose. | - | +| errorMessage | `ReactNode \| (v: ValidationResult) => ReactNode` | An error message for the date input. | - | +| validate | `(value: MappedDateValue) => ValidationError | true | null | undefined` | Validate input values when committing (e.g., on blur), and return error messages for invalid values. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA.| `aria` | +| startContent | `ReactNode` | Element to be rendered in the left side of the date input. | - | +| endContent | `ReactNode` | Element to be rendered in the right side of the date input. | - | +| labelPlacement | `inside` \| `outside` \| `outside-left` | The position of the label. | `inside` | +| isRequired | `boolean` | Whether user input is required on the input before form submission. | `false` | +| isReadOnly | `boolean` | Whether the input can be selected but not changed by the user. | - | +| isDisabled | `boolean` | Whether the input is disabled. | `false` | +| isInvalid | `boolean` | Whether the input value is invalid. | `false` | +| inputRef | `ReactRef` | A ref for the hidden input element for HTML form submission. | - | +| createCalendar | `(name: string) => Calendar` | A function that creates a Calendar object for a given calendar identifier. | - | +| isDateUnavailable | `(date: DateValue) => boolean` | Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. | - | +| autoFocus | `boolean` | Whether the element should receive focus on render. | `false` | +| hourCycle | `12` \| `24` | Whether to display the time in 12 or 24 hour format. This is determined by the user's locale. | - | +| granularity | `day` \| `hour` \| `minute` \| `second` | Determines the smallest unit that is displayed in the date picker. Typically "day" for dates. | - | +| hideTimeZone | `boolean` | Whether to hide the time zone abbreviation. | `false` | +| shouldForceLeadingZeros | `boolean` | Whether to always show leading zeros in the month, day, and hour fields. | `true` | +| disableAnimation | `boolean` | Whether to disable animations. | `false` | +| classNames | `Record<"base"| "label"| "inputWrapper"| "innerWrapper"| "input"| "helperWrapper"| "description"| "errorMessage", string>` | Allows to set custom class names for the date input slots. | - | ### DateInput Events diff --git a/apps/docs/content/docs/components/date-picker.mdx b/apps/docs/content/docs/components/date-picker.mdx index a0614d6813..0a305b854e 100644 --- a/apps/docs/content/docs/components/date-picker.mdx +++ b/apps/docs/content/docs/components/date-picker.mdx @@ -301,46 +301,47 @@ import {I18nProvider} from "@react-aria/i18n"; ### DatePicker Props -| Attribute | Type | Description | Default | -| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| label | `ReactNode` | The content to display as the label. | - | -| value | `ZonedDateTime` \| `CalendarDate` \| `CalendarDateTime` \| `undefined` \| `null` | The current value of the date-picker (controlled). | - | -| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the date input. | `flat` | -| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the date input. | `default` | -| size | `sm` \| `md` \| `lg` | The size of the date input. | `md` | -| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the date input. | - | -| defaultValue | `string` \| undefined | The default value of the date-picker (uncontrolled). | - | -| placeholderValue | `ZonedDateTime` \| `CalendarDate` \| `CalendarDateTime` \| `undefined` \| `null` | The placeholder of the date-picker. | - | -| description | `ReactNode` | A description for the date-picker. Provides a hint such as specific requirements for what to choose. | - | -| errorMessage | `ReactNode \| (v: ValidationResult) => ReactNode` | An error message for the date input. | - | -| startContent | `ReactNode` | Element to be rendered in the left side of the date-picker. | - | -| endContent | `ReactNode` | Element to be rendered in the right side of the date-picker. | - | -| labelPlacement | `inside` \| `outside` \| `outside-left` | The position of the label. | `inside` | -| isRequired | `boolean` | Whether user input is required on the date-picker before form submission. | `false` | -| isReadOnly | `boolean` | Whether the date-picker can be selected but not changed by the user. | | -| isDisabled | `boolean` | Whether the date-picker is disabled. | `false` | -| isInvalid | `boolean` | Whether the date-picker is invalid. | `false` | -| visibleMonths | `number` \| `undefined` | The number of months to display at once. Up to 3 months are supported. Passing a number greater than 1 will disable the `showMonthAndYearPickers` prop. | `1` | -| selectorIcon | `ReactNode` | The icon to toggle the date picker popover. Usually a calendar icon. | | -| pageBehavior | `PageBehavior` \| `undefined` | Controls the behavior of paging. Pagination either works by advancing the visible page by visibleDuration (default) or one unit of visibleDuration. | `visible` | -| visibleMonths | `number` \| `undefined` | The number of months to display at once. Up to 3 months are supported. Passing a number greater than 1 will disable the `showMonthAndYearPickers` prop. | `1` | -| calendarWidth | `number` | The width to be applied to the calendar component. | `256` | -| CalendarTopContent | `ReactNode` | Top content to be rendered in the calendar component. | | -| 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. | - | -| isDateUnavailable | `((date: DateValue) => boolean)` \| `undefined` | Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. | -| autoFocus | `boolean` | Whether the element should receive focus on render. | `false` | -| hourCycle | `12` \| `24` | Whether to display the time in 12 or 24 hour format. This is determined by the user's locale. | - | -| granularity | `day` \| `hour` \| `minute` \| `second` | Determines the smallest unit that is displayed in the date picker. Typically "day" for dates. | - | -| hideTimeZone | `boolean` | Whether to hide the time zone abbreviation. | `false` | -| shouldForceLeadingZeros | `boolean` | Whether to always show leading zeros in the month, day, and hour fields. | `true` | -| CalendarBottomContent | `ReactNode` | Bottom content to be rendered in the calendar component. | | -| showMonthAndYearPickers | `boolean` \| `undefined` | Whether the calendar should show month and year pickers. | false | -| popoverProps | `PopoverProps` \| `undefined` | Props to be passed to the popover component. | `{ placement: "bottom", triggerScaleOnOpen: false, offset: 13 }` | -| selectorButtonProps | `ButtonProps` \| `undefined` | Props to be passed to the selector button component. | `{ size: "sm", variant: "light", radius: "full", isIconOnly: true }` | -| calendarProps | `CalendarProps` \| `undefined` | Props to be passed to the selector button component. | `{ size: "sm", variant: "light", radius: "full", isIconOnly: true }` | -| timeInputProps | `TimeInputProps` | Props to be passed to the time input component. | `{ size: "sm", variant: "light", radius: "full", isIconOnly: true }` | -| disableAnimation | `boolean` | Whether to disable all animations in the date picker. Including the DateInput, Button, Calendar, and Popover. | `false` | -| classNames | `Record<"base" \| "selectorButton" \| "selectorIcon" \| "popoverContent" \| "calendar" \| "calendarContent" \| "timeInputLabel" \| "timeInput", string>` | Allows to set custom class names for the date-picker slots. | - | +| Attribute | Type | Description | Default | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| label | `ReactNode` | The content to display as the label. | - | +| value | `ZonedDateTime` \| `CalendarDate` \| `CalendarDateTime` \| `undefined` \| `null` | The current value of the date-picker (controlled). | - | +| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the date input. | `flat` | +| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the date input. | `default` | +| size | `sm` \| `md` \| `lg` | The size of the date input. | `md` | +| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the date input. | - | +| defaultValue | `string` \| undefined | The default value of the date-picker (uncontrolled). | - | +| placeholderValue | `ZonedDateTime` \| `CalendarDate` \| `CalendarDateTime` \| `undefined` \| `null` | The placeholder of the date-picker. | - | +| description | `ReactNode` | A description for the date-picker. Provides a hint such as specific requirements for what to choose. | - | +| errorMessage | `ReactNode \| (v: ValidationResult) => ReactNode` | An error message for the date input. | - | +| validate | `(value: MappedDateValue) => ValidationError | true | null | undefined` | Validate input values when committing (e.g., on blur), and return error messages for invalid values. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA.| `aria` | +| startContent | `ReactNode` | Element to be rendered in the left side of the date-picker. | - | +| endContent | `ReactNode` | Element to be rendered in the right side of the date-picker. | - | +| labelPlacement | `inside` \| `outside` \| `outside-left` | The position of the label. | `inside` | +| isRequired | `boolean` | Whether user input is required on the date-picker before form submission. | `false` | +| isReadOnly | `boolean` | Whether the date-picker can be selected but not changed by the user. | | +| isDisabled | `boolean` | Whether the date-picker is disabled. | `false` | +| isInvalid | `boolean` | Whether the date-picker is invalid. | `false` | +| visibleMonths | `number` \| `undefined` | The number of months to display at once. Up to 3 months are supported. Passing a number greater than 1 will disable the `showMonthAndYearPickers` prop. | `1` | +| selectorIcon | `ReactNode` | The icon to toggle the date picker popover. Usually a calendar icon. | | +| pageBehavior | `PageBehavior` \| `undefined` | Controls the behavior of paging. Pagination either works by advancing the visible page by visibleDuration (default) or one unit of visibleDuration. | `visible` | +| visibleMonths | `number` \| `undefined` | The number of months to display at once. Up to 3 months are supported. Passing a number greater than 1 will disable the `showMonthAndYearPickers` prop. | `1` | +| calendarWidth | `number` | The width to be applied to the calendar component. | `256` | +| CalendarTopContent | `ReactNode` | Top content to be rendered in the calendar component. | | +| isDateUnavailable | `((date: DateValue) => boolean)` \| `undefined` | Callback that is called for each date of the calendar. If it returns true, then the date is unavailable. | +| autoFocus | `boolean` | Whether the element should receive focus on render. | `false` | +| hourCycle | `12` \| `24` | Whether to display the time in 12 or 24 hour format. This is determined by the user's locale. | - | +| granularity | `day` \| `hour` \| `minute` \| `second` | Determines the smallest unit that is displayed in the date picker. Typically "day" for dates. | - | +| hideTimeZone | `boolean` | Whether to hide the time zone abbreviation. | `false` | +| shouldForceLeadingZeros | `boolean` | Whether to always show leading zeros in the month, day, and hour fields. | `true` | +| CalendarBottomContent | `ReactNode` | Bottom content to be rendered in the calendar component. | | +| showMonthAndYearPickers | `boolean` \| `undefined` | Whether the calendar should show month and year pickers. | false | +| popoverProps | `PopoverProps` \| `undefined` | Props to be passed to the popover component. | `{ placement: "bottom", triggerScaleOnOpen: false, offset: 13 }` | +| selectorButtonProps | `ButtonProps` \| `undefined` | Props to be passed to the selector button component. | `{ size: "sm", variant: "light", radius: "full", isIconOnly: true }` | +| calendarProps | `CalendarProps` \| `undefined` | Props to be passed to the selector button component. | `{ size: "sm", variant: "light", radius: "full", isIconOnly: true }` | +| timeInputProps | `TimeInputProps` | Props to be passed to the time input component. | `{ size: "sm", variant: "light", radius: "full", isIconOnly: true }` | +| disableAnimation | `boolean` | Whether to disable all animations in the date picker. Including the DateInput, Button, Calendar, and Popover. | `false` | +| classNames | `Record<"base" \| "selectorButton" \| "selectorIcon" \| "popoverContent" \| "calendar" \| "calendarContent" \| "timeInputLabel" \| "timeInput", string>` | Allows to set custom class names for the date-picker slots. | - | ### DatePicker Events diff --git a/apps/docs/content/docs/components/date-range-picker.mdx b/apps/docs/content/docs/components/date-range-picker.mdx index bb808d1dde..9d24d14332 100644 --- a/apps/docs/content/docs/components/date-range-picker.mdx +++ b/apps/docs/content/docs/components/date-range-picker.mdx @@ -354,6 +354,8 @@ import {I18nProvider} from "@react-aria/i18n"; | placeholderValue | `ZonedDateTime` \| `CalendarDate` \| `CalendarDateTime` \| `undefined` \| `null` | The placeholder of the date-range-picker. | - | | description | `ReactNode` | A description for the date-range-picker. Provides a hint such as specific requirements for what to choose. | - | | errorMessage | `ReactNode \| (v: ValidationResult) => ReactNode` | An error message for the date input. | - | +| validate | `(value: RangeValue>) => ValidationError | true | null | undefined` | Validate input values when committing (e.g., on blur), and return error messages for invalid values. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA. | `aria` | | startContent | `ReactNode` | Element to be rendered in the left side of the date-range-picker. | - | | endContent | `ReactNode` | Element to be rendered in the right side of the date-range-picker. | - | | startName | `string` | The name of the start date input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname) | - | @@ -367,7 +369,6 @@ import {I18nProvider} from "@react-aria/i18n"; | isInvalid | `boolean` | Whether the date-range-picker is invalid. | `false` | | selectorIcon | `ReactNode` | The icon to toggle the date picker popover. Usually a calendar icon. | | | pageBehavior | `single` \| `visible` | Controls the behavior of paging. Pagination either works by advancing the visible page by visibleDuration (default) or one unit of visibleDuration. | `visible` | -| 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. | - | | visibleMonths | `number` | The number of months to display at once. Up to 3 months are supported. | `1` | | autoFocus | `boolean` | Whether the element should receive focus on render. | `false` | | hourCycle | `12` \| `24` | Whether to display the time in 12 or 24 hour format. This is determined by the user's locale. | - | diff --git a/apps/docs/content/docs/components/input.mdx b/apps/docs/content/docs/components/input.mdx index 2107efe4ee..35b0df3db1 100644 --- a/apps/docs/content/docs/components/input.mdx +++ b/apps/docs/content/docs/components/input.mdx @@ -194,33 +194,34 @@ In case you need to customize the input even further, you can use the `useInput` ### Input Props -| Attribute | Type | Description | Default | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------- | -| children | `ReactNode` | The content of the input. | - | -| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the input. | `flat` | -| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the input. | `default` | -| size | `sm` \| `md` \| `lg` | The size of the input. | `md` | -| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the input. | - | -| label | `ReactNode` | The content to display as the label. | - | -| value | `string` | The current value of the input (controlled). | - | -| 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` \| `((v: ValidationResult) => ReactNode)` | An error message for the input. It is only shown when `isInvalid` is set to `true` | - | -| 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` | -| fullWidth | `boolean` | Whether the input should take up the width of its parent. | `true` | -| isClearable | `boolean` | Whether the input should have a clear button. | `false` | -| isRequired | `boolean` | Whether user input is required on the input before form submission. | `false` | -| isReadOnly | `boolean` | Whether the input can be selected but not changed by the user. | | -| isDisabled | `boolean` | Whether the input is disabled. | `false` | -| isInvalid | `boolean` | Whether the input is invalid. | `false` | -| baseRef | `RefObject` | The ref to the base element. | - | -| validationState | `valid` \| `invalid` | Whether the input should display its "valid" or "invalid" visual styling. (**Deprecated**) use **isInvalid** instead. | - | -| disableAnimation | `boolean` | Whether the input should be animated. | `false` | -| classNames | `Record<"base"| "label"| "inputWrapper"| "innerWrapper"| "mainWrapper" | "input" | "clearButton" | "helperWrapper" | "description" | "errorMessage", string>` | Allows to set custom class names for the Input slots. | - | +| Attribute | Type | Description | Default | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| children | `ReactNode` | The content of the input. | - | +| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the input. | `flat` | +| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the input. | `default` | +| size | `sm` \| `md` \| `lg` | The size of the input. | `md` | +| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the input. | - | +| label | `ReactNode` | The content to display as the label. | - | +| value | `string` | The current value of the input (controlled). | - | +| 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` \| `((v: ValidationResult) => ReactNode)` | An error message for the input. It is only shown when `isInvalid` is set to `true` | - | +| validate | `(value: string) => ValidationError | true | null | undefined` | Validate input values when committing (e.g. on blur), and return error messages for invalid values. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA.| `aria` | +| 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` | +| fullWidth | `boolean` | Whether the input should take up the width of its parent. | `true` | +| isClearable | `boolean` | Whether the input should have a clear button. | `false` | +| isRequired | `boolean` | Whether user input is required on the input before form submission. | `false` | +| isReadOnly | `boolean` | Whether the input can be selected but not changed by the user. | | +| isDisabled | `boolean` | Whether the input is disabled. | `false` | +| isInvalid | `boolean` | Whether the input is invalid. | `false` | +| baseRef | `RefObject` | The ref to the base element. | - | +| validationState | `valid` \| `invalid` | Whether the input should display its "valid" or "invalid" visual styling. (**Deprecated**) use **isInvalid** instead. | - | +| disableAnimation | `boolean` | Whether the input should be animated. | `false` | +| classNames | `Record<"base"| "label"| "inputWrapper"| "innerWrapper"| "mainWrapper" | "input" | "clearButton" | "helperWrapper" | "description" | "errorMessage", string>` | Allows to set custom class names for the Input slots. | - | ### Input Events diff --git a/apps/docs/content/docs/components/radio-group.mdx b/apps/docs/content/docs/components/radio-group.mdx index 762280b523..8bdfaa350e 100644 --- a/apps/docs/content/docs/components/radio-group.mdx +++ b/apps/docs/content/docs/components/radio-group.mdx @@ -147,26 +147,27 @@ In case you need to customize the radio group even further, you can use the `use ### RadioGroup Props -| Attribute | Type | Description | Default | -| ---------------- | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -| children | `ReactNode` \| `ReactNode[]` | The list of radio elements. | - | -| label | `ReactNode` | The label of the radio group. | - | -| size | `sm` \| `md` \| `lg` | The size of the radios. | `md` | -| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the radios. | `primary` | -| orientation | `horizontal` \| `vertical` | The orientation of the radio group. | `vertical` | -| name | `string` | The name of the RadioGroup, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#name_and_radio_buttons). | - | -| value | `string[]` | The current selected value. (controlled). | - | -| defaultValue | `string[]` | The default selected value. (uncontrolled). | - | -| description | `ReactNode` | Radio group description . | - | -| 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. | - | -| isInvalid | `boolean` | Whether the radio group is invalid. | `false` | -| validationState | `valid` \| `invalid` | Whether the inputs should display its "valid" or "invalid" visual styling. (**Deprecated**) use **isInvalid** instead. | `false` | -| disableAnimation | `boolean` | Whether the animation should be disabled. | `false` | -| classNames | `Record<"base"| "wrapper"| "label", string>` | Allows to set custom class names for the radio group slots. | - | +| Attribute | Type | Description | Default | +| ------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| children | `ReactNode` \| `ReactNode[]` | The list of radio elements. | - | +| label | `ReactNode` | The label of the radio group. | - | +| size | `sm` \| `md` \| `lg` | The size of the radios. | `md` | +| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the radios. | `primary` | +| orientation | `horizontal` \| `vertical` | The orientation of the radio group. | `vertical` | +| name | `string` | The name of the RadioGroup, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#name_and_radio_buttons). | - | +| value | `string[]` | The current selected value. (controlled). | - | +| defaultValue | `string[]` | The default selected value. (uncontrolled). | - | +| description | `ReactNode` | Radio group description . | - | +| 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. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA.| `aria` | +| 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. | - | +| isInvalid | `boolean` | Whether the radio group is invalid. | `false` | +| validationState | `valid` \| `invalid` | Whether the inputs should display its "valid" or "invalid" visual styling. (**Deprecated**) use **isInvalid** instead. | `false` | +| disableAnimation | `boolean` | Whether the animation should be disabled. | `false` | +| classNames | `Record<"base"| "wrapper"| "label", string>` | Allows to set custom class names for the radio group slots. | - | ### RadioGroup Events diff --git a/apps/docs/content/docs/components/textarea.mdx b/apps/docs/content/docs/components/textarea.mdx index 5c2551e153..0148ce2825 100644 --- a/apps/docs/content/docs/components/textarea.mdx +++ b/apps/docs/content/docs/components/textarea.mdx @@ -139,35 +139,36 @@ You can use the `value` and `onValueChange` properties to control the input valu ### Textarea Props -| Attribute | Type | Description | Default | -| ----------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | --------- | -| children | `ReactNode` | The content of the textarea. | - | -| minRows | `number` | The minimum number of rows to display. | `3` | -| maxRows | `number` | Maximum number of rows up to which the textarea can grow. | `8` | -| cacheMeasurements | `boolean` | Reuse previously computed measurements when computing height of textarea. | `false` | -| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the textarea. | `flat` | -| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the textarea. | `default` | -| size | `sm`\|`md`\|`lg` | The size of the textarea. | `md` | -| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the textarea. | - | -| label | `ReactNode` | The content to display as the label. | - | -| value | `string` | The current value of the textarea (controlled). | - | -| defaultValue | `string` | The default value of the textarea (uncontrolled). | - | -| placeholder | `string` | The placeholder of the textarea. | - | -| 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` \| `((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` | -| isReadOnly | `boolean` | Whether the textarea can be selected but not changed by the user. | | -| isDisabled | `boolean` | Whether the textarea is disabled. | `false` | -| isInvalid | `boolean` | Whether the textarea is invalid. | `false` | -| 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. | - | +| Attribute | Type | Description | Default | +| ------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| children | `ReactNode` | The content of the textarea. | - | +| minRows | `number` | The minimum number of rows to display. | `3` | +| maxRows | `number` | Maximum number of rows up to which the textarea can grow. | `8` | +| cacheMeasurements | `boolean` | Reuse previously computed measurements when computing height of textarea. | `false` | +| variant | `flat` \| `bordered` \| `faded` \| `underlined` | The variant of the textarea. | `flat` | +| color | `default` \| `primary` \| `secondary` \| `success` \| `warning` \| `danger` | The color of the textarea. | `default` | +| size | `sm`\|`md`\|`lg` | The size of the textarea. | `md` | +| radius | `none` \| `sm` \| `md` \| `lg` \| `full` | The radius of the textarea. | - | +| label | `ReactNode` | The content to display as the label. | - | +| value | `string` | The current value of the textarea (controlled). | - | +| defaultValue | `string` | The default value of the textarea (uncontrolled). | - | +| placeholder | `string` | The placeholder of the textarea. | - | +| 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` \| `((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. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA.| `aria` | +| 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` | +| isReadOnly | `boolean` | Whether the textarea can be selected but not changed by the user. | | +| isDisabled | `boolean` | Whether the textarea is disabled. | `false` | +| isInvalid | `boolean` | Whether the textarea is invalid. | `false` | +| 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. | - | ### Input Events diff --git a/apps/docs/content/docs/components/time-input.mdx b/apps/docs/content/docs/components/time-input.mdx index c35afc725b..6777f25753 100644 --- a/apps/docs/content/docs/components/time-input.mdx +++ b/apps/docs/content/docs/components/time-input.mdx @@ -223,9 +223,10 @@ By default, `TimeInput` displays times in either 12 or 24 hour hour format depen | autoFocus | `boolean` | Whether the element should receive focus on render. | - | | description | `ReactNode` | A description for the field. Provides a hint such as specific requirements for what to choose. | - | | errorMessage | `ReactNode \| (v: ValidationResult) => ReactNode` | An error message for the field. | - | -| validate | `(value: { inputValue: string, selectedKey: React.Key }) => ValidationError | true | null | undefined` | Validate time input values when committing (e.g. on blur), and return error messages for invalid values. | - | +| validate | `(value: MappedTimeValue) => ValidationError | true | null | undefined` | Validate time input values when committing (e.g. on blur), and return error messages for invalid values. | - | +| validationBehavior | `native` \| `aria` | Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA. | `aria` | | disableAnimation | `boolean` | Whether to disable the animation of the time input. | - | -| classNames | `Record<"base"| "label"| "inputWrapper"| "innerWrapper" | "segment" | "helperWrapper" | "input" | "description" | "errorMessage", string>` | Allows to set custom class names for the time input slots. | - | +| classNames | `Record<"base"| "label"| "inputWrapper"| "innerWrapper" | "segment" | "helperWrapper" | "input" | "description" | "errorMessage", string>` | Allows to set custom class names for the time input slots. | - | ### TimeInput Events diff --git a/packages/components/autocomplete/__tests__/autocomplete.test.tsx b/packages/components/autocomplete/__tests__/autocomplete.test.tsx index f9b166f245..0fda8e0010 100644 --- a/packages/components/autocomplete/__tests__/autocomplete.test.tsx +++ b/packages/components/autocomplete/__tests__/autocomplete.test.tsx @@ -1,9 +1,9 @@ import * as React from "react"; -import {render, renderHook, act} from "@testing-library/react"; +import {within, render, renderHook, act} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import {useForm} from "react-hook-form"; -import {Autocomplete, AutocompleteItem, AutocompleteSection} from "../src"; +import {Autocomplete, AutocompleteItem, AutocompleteProps, AutocompleteSection} from "../src"; import {Modal, ModalContent, ModalBody, ModalHeader, ModalFooter} from "../../modal/src"; type Item = { @@ -48,21 +48,23 @@ const itemsSectionData = [ }, ]; +const AutocompleteExample = (props: Partial = {}) => ( + + + Penguin + + + Zebra + + + Shark + + +); + describe("Autocomplete", () => { it("should render correctly", () => { - const wrapper = render( - - - Penguin - - - Zebra - - - Shark - - , - ); + const wrapper = render(); expect(() => wrapper.unmount()).not.toThrow(); }); @@ -83,6 +85,7 @@ describe("Autocomplete", () => { , ); + expect(ref.current).not.toBeNull(); }); @@ -220,6 +223,85 @@ describe("Autocomplete", () => { // assert that the autocomplete dropdown is closed expect(autocomplete).toHaveAttribute("aria-expanded", "false"); }); + + describe("validation", () => { + let user; + + beforeAll(() => { + user = userEvent.setup(); + }); + + describe("validationBehavior=native", () => { + it("supports isRequired", async () => { + const {getByTestId, getByRole, findByRole} = render( +
+ + , + ); + + const input = getByRole("combobox") as HTMLInputElement; + + expect(input).toHaveAttribute("required"); + expect(input).not.toHaveAttribute("aria-required"); + expect(input).not.toHaveAttribute("aria-describedby"); + expect(input.validity.valid).toBe(false); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(input).toHaveAttribute("aria-describedby"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Constraints not satisfied", + ); + + await user.click(input); + await user.keyboard("pe"); + + const listbox = await findByRole("listbox"); + const items = within(listbox).getAllByRole("option"); + + await user.click(items[0]); + expect(input).toHaveAttribute("aria-describedby"); + }); + }); + + describe("validationBehavior=aria", () => { + it("supports validate function", async () => { + let {getByRole, findByRole} = render( +
+ (v === "Penguin" ? "Invalid value" : null)} + validationBehavior="aria" + /> + , + ); + + const input = getByRole("combobox") as HTMLInputElement; + + expect(input).toHaveAttribute("aria-describedby"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid value", + ); + expect(input.validity.valid).toBe(true); + + await user.tab(); + await user.click(); + // open the select dropdown + await user.keyboard("{ArrowDown}"); + + const listbox = await findByRole("listbox"); + const item = within(listbox).getByRole("option", {name: "Zebra"}); + + await user.click(item); + + expect(input).not.toHaveAttribute("aria-describedby"); + expect(input).not.toHaveAttribute("aria-invalid"); + }); + }); + }); }); describe("Autocomplete with React Hook Form", () => { diff --git a/packages/components/autocomplete/src/use-autocomplete.ts b/packages/components/autocomplete/src/use-autocomplete.ts index 0bb30dc6b5..9c2c370dda 100644 --- a/packages/components/autocomplete/src/use-autocomplete.ts +++ b/packages/components/autocomplete/src/use-autocomplete.ts @@ -112,11 +112,8 @@ interface Props extends Omit, keyof ComboBoxProps } export type UseAutocompleteProps = Props & - Omit< - InputProps, - "children" | "value" | "isClearable" | "defaultValue" | "classNames" | "validationBehavior" - > & - Omit, "validationBehavior"> & + Omit & + ComboBoxProps & AsyncLoadable & AutocompleteVariantProps; @@ -160,6 +157,7 @@ export function useAutocomplete(originalProps: UseAutocomplete clearButtonProps = {}, showScrollIndicators = true, allowsCustomValue = false, + validationBehavior = globalContext?.validationBehavior ?? "aria", className, classNames, errorMessage, @@ -176,7 +174,7 @@ export function useAutocomplete(originalProps: UseAutocomplete ...originalProps, children, menuTrigger, - validationBehavior: "native", + validationBehavior, shouldCloseOnBlur, allowsEmptyCollection, defaultFilter: defaultFilter && typeof defaultFilter === "function" ? defaultFilter : contains, @@ -212,7 +210,7 @@ export function useAutocomplete(originalProps: UseAutocomplete validationErrors, } = useComboBox( { - validationBehavior: "native", + validationBehavior, ...originalProps, inputRef, buttonRef, @@ -420,6 +418,7 @@ export function useAutocomplete(originalProps: UseAutocomplete ...inputProps, ...slotsProps.inputProps, isInvalid, + validationBehavior, errorMessage: typeof errorMessage === "function" ? errorMessage({isInvalid, validationErrors, validationDetails}) diff --git a/packages/components/autocomplete/stories/autocomplete.stories.tsx b/packages/components/autocomplete/stories/autocomplete.stories.tsx index 4ea9dae2db..022e249e6c 100644 --- a/packages/components/autocomplete/stories/autocomplete.stories.tsx +++ b/packages/components/autocomplete/stories/autocomplete.stories.tsx @@ -64,6 +64,12 @@ export default { type: "boolean", }, }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, }, decorators: [ (Story) => ( @@ -890,11 +896,13 @@ export const WithValidation = { args: { ...defaultProps, - isRequired: true, + label: "Select Cat or Dog", validate: (value) => { - if (value.inputValue === "Cat" || value.selectedKey === "dog") { - return "Please select a valid animal"; + if (value.selectedKey == null || value.selectedKey === "cat" || value.selectedKey === "dog") { + return; } + + return "Please select a valid animal"; }, }, }; diff --git a/packages/components/checkbox/__tests__/checkbox-group.test.tsx b/packages/components/checkbox/__tests__/checkbox-group.test.tsx index cef6cdd5b1..68ea6e93b5 100644 --- a/packages/components/checkbox/__tests__/checkbox-group.test.tsx +++ b/packages/components/checkbox/__tests__/checkbox-group.test.tsx @@ -139,11 +139,11 @@ describe("Checkbox.Group", () => { beforeAll(() => { user = userEvent.setup(); }); - describe("validationBehavior=native (default)", () => { + describe("validationBehavior=native", () => { it("supports group level isRequired", async () => { let {getAllByRole, getByRole, getByTestId} = render(
- + Terms and conditions Cookies Privacy policy @@ -181,6 +181,148 @@ describe("Checkbox.Group", () => { expect(group).not.toHaveAttribute("aria-describedby"); }); + + it("supports checkbox 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.slice(0, 2)) { + expect(input).toHaveAttribute("required"); + expect(input).not.toHaveAttribute("aria-required"); + expect(input.validity.valid).toBe(false); + } + expect(checkboxes[2]).not.toHaveAttribute("required"); + expect(checkboxes[2]).not.toHaveAttribute("aria-required"); + expect(checkboxes[2].validity.valid).toBe(true); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(group).toHaveAttribute("aria-describedby"); + expect(document.getElementById(group.getAttribute("aria-describedby")!)).toHaveTextContent( + "Constraints not satisfied", + ); + expect(document.activeElement).toBe(checkboxes[0]); + + await user.click(checkboxes[0]); + await user.click(checkboxes[1]); + expect(checkboxes[0].validity.valid).toBe(true); + expect(checkboxes[1].validity.valid).toBe(true); + expect(group).not.toHaveAttribute("aria-describedby"); + }); + + it("supports group level validate function", async () => { + let {getAllByRole, getByRole, getByTestId} = render( +
+ (v.length < 3 ? "You must accept all terms" : null)} + validationBehavior="native" + > + 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).not.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( + "You must accept all terms", + ); + expect(document.activeElement).toBe(checkboxes[0]); + + await user.click(checkboxes[0]); + expect(group).toHaveAttribute("aria-describedby"); + for (let input of checkboxes) { + expect(input.validity.valid).toBe(false); + } + + await user.click(checkboxes[1]); + expect(group).toHaveAttribute("aria-describedby"); + for (let input of checkboxes) { + expect(input.validity.valid).toBe(false); + } + + await user.click(checkboxes[2]); + expect(group).not.toHaveAttribute("aria-describedby"); + for (let input of checkboxes) { + expect(input.validity.valid).toBe(true); + } + }); + }); + + describe("validationBehavior=aria", () => { + it("supports group level validate function", async () => { + let {getAllByRole, getByRole} = render( + (v.length < 3 ? "You must accept all terms" : null)} + validationBehavior="aria" + > + Terms and conditions + Cookies + Privacy policy + , + ); + + let group = getByRole("group"); + + expect(group).toHaveAttribute("aria-describedby"); + expect(document.getElementById(group.getAttribute("aria-describedby")!)).toHaveTextContent( + "You must accept all terms", + ); + + let checkboxes = getAllByRole("checkbox") as HTMLInputElement[]; + + for (let input of checkboxes) { + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input.validity.valid).toBe(true); + } + + await user.click(checkboxes[0]); + expect(group).toHaveAttribute("aria-describedby"); + + await user.click(checkboxes[1]); + expect(group).toHaveAttribute("aria-describedby"); + + await user.click(checkboxes[2]); + expect(group).toHaveAttribute("aria-describedby"); + }); }); }); }); diff --git a/packages/components/checkbox/__tests__/checkbox.test.tsx b/packages/components/checkbox/__tests__/checkbox.test.tsx index 8e854a4a38..462f93e973 100644 --- a/packages/components/checkbox/__tests__/checkbox.test.tsx +++ b/packages/components/checkbox/__tests__/checkbox.test.tsx @@ -90,10 +90,26 @@ describe("Checkbox", () => { expect(onFocus).toBeCalled(); }); - it('should work correctly with "isRequired" prop', () => { - const {container} = render(Option); + it("should have required attribute when isRequired with native validationBehavior", () => { + const {container} = render( + + Option + , + ); + + expect(container.querySelector("input")).toHaveAttribute("required"); + expect(container.querySelector("input")).not.toHaveAttribute("aria-required"); + }); + + it("should have aria-required attribute when isRequired with aria validationBehavior", () => { + const {container} = render( + + Option + , + ); - expect(container.querySelector("input")?.required).toBe(true); + expect(container.querySelector("input")).not.toHaveAttribute("required"); + expect(container.querySelector("input")).toHaveAttribute("aria-required", "true"); }); it("should work correctly with controlled value", () => { @@ -128,6 +144,60 @@ describe("Checkbox", () => { expect(onChange).toBeCalled(); }); + + describe("validation", () => { + let user; + + beforeEach(() => { + user = userEvent.setup(); + }); + + describe("validationBehavior=native", () => { + it("supports isRequired", async () => { + const {getByRole, getByTestId} = render( +
+ + Terms and conditions + +
, + ); + + const checkbox = getByRole("checkbox") as HTMLInputElement; + + expect(checkbox).toHaveAttribute("required"); + expect(checkbox).not.toHaveAttribute("aria-required"); + expect(checkbox.validity.valid).toBe(false); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + await user.click(checkbox); + expect(checkbox.validity.valid).toBe(true); + }); + }); + + describe("validationBehavior=aria", () => { + it("supports validate function", async () => { + const {getByRole} = render( + (!v ? "You must accept the terms." : null)} + validationBehavior="aria" + value="terms" + > + Terms and conditions + , + ); + + const checkbox = getByRole("checkbox") as HTMLInputElement; + + expect(checkbox.validity.valid).toBe(true); + + await user.click(checkbox); + expect(checkbox.validity.valid).toBe(true); + }); + }); + }); }); describe("Checkbox with React Hook Form", () => { diff --git a/packages/components/checkbox/src/use-checkbox-group.ts b/packages/components/checkbox/src/use-checkbox-group.ts index b02e96c1c0..c351cc93e6 100644 --- a/packages/components/checkbox/src/use-checkbox-group.ts +++ b/packages/components/checkbox/src/use-checkbox-group.ts @@ -48,7 +48,7 @@ interface Props extends HTMLNextUIProps<"div"> { } export type UseCheckboxGroupProps = Omit & - Omit & + AriaCheckboxGroupProps & Partial< Pick< CheckboxProps, @@ -65,6 +65,7 @@ export type ContextType = { lineThrough?: CheckboxProps["lineThrough"]; isDisabled?: CheckboxProps["isDisabled"]; disableAnimation?: CheckboxProps["disableAnimation"]; + validationBehavior?: CheckboxProps["validationBehavior"]; }; export function useCheckboxGroup(props: UseCheckboxGroupProps) { @@ -87,6 +88,7 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { orientation = "vertical", lineThrough = false, isDisabled = false, + validationBehavior = globalContext?.validationBehavior ?? "aria", disableAnimation = globalContext?.disableAnimation ?? false, isReadOnly, isRequired, @@ -112,7 +114,7 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { isRequired, isReadOnly, orientation, - validationBehavior: "native", + validationBehavior, isInvalid: validationState === "invalid" || isInvalidProp, onChange: chain(props.onChange, onValueChange), }; @@ -127,6 +129,7 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { onValueChange, isInvalidProp, validationState, + validationBehavior, otherProps["aria-label"], otherProps, ]); @@ -138,22 +141,20 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { groupProps, descriptionProps, errorMessageProps, - isInvalid: isAriaInvalid, validationErrors, validationDetails, } = useReactAriaCheckboxGroup(checkboxGroupProps, groupState); - let isInvalid = checkboxGroupProps.isInvalid || isAriaInvalid; - const context = useMemo( () => ({ size, color, radius, lineThrough, - isInvalid, + isInvalid: groupState.isInvalid, isDisabled, disableAnimation, + validationBehavior, groupState, }), [ @@ -163,18 +164,18 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { lineThrough, isDisabled, disableAnimation, - isInvalid, - groupState?.value, - groupState?.isDisabled, - groupState?.isReadOnly, - groupState?.isInvalid, - groupState?.isSelected, + validationBehavior, + groupState.value, + groupState.isDisabled, + groupState.isReadOnly, + groupState.isInvalid, + groupState.isSelected, ], ); const slots = useMemo( - () => checkboxGroup({isRequired, isInvalid, disableAnimation}), - [isRequired, isInvalid, disableAnimation], + () => checkboxGroup({isRequired, isInvalid: groupState.isInvalid, disableAnimation}), + [isRequired, groupState.isInvalid, , disableAnimation], ); const baseStyles = clsx(classNames?.base, className); @@ -235,10 +236,10 @@ export function useCheckboxGroup(props: UseCheckboxGroupProps) { label, context, description, - isInvalid, + isInvalid: groupState.isInvalid, errorMessage: typeof errorMessage === "function" - ? errorMessage({isInvalid, validationErrors, validationDetails}) + ? errorMessage({isInvalid: groupState.isInvalid, validationErrors, validationDetails}) : errorMessage || validationErrors?.join(" "), getGroupProps, getLabelProps, diff --git a/packages/components/checkbox/src/use-checkbox.ts b/packages/components/checkbox/src/use-checkbox.ts index 75c4fb185f..402488c89d 100644 --- a/packages/components/checkbox/src/use-checkbox.ts +++ b/packages/components/checkbox/src/use-checkbox.ts @@ -68,7 +68,7 @@ interface Props extends Omit, keyof CheckboxVariantProp } export type UseCheckboxProps = Omit & - Omit & + Omit & CheckboxVariantProps; export function useCheckbox(props: UseCheckboxProps = {}) { @@ -87,15 +87,16 @@ export function useCheckbox(props: UseCheckboxProps = {}) { isReadOnly: isReadOnlyProp = false, autoFocus = false, isSelected: isSelectedProp, - validationState, size = groupContext?.size ?? "md", color = groupContext?.color ?? "primary", radius = groupContext?.radius, lineThrough = groupContext?.lineThrough ?? false, isDisabled: isDisabledProp = groupContext?.isDisabled ?? false, disableAnimation = groupContext?.disableAnimation ?? globalContext?.disableAnimation ?? false, + validationState, isInvalid = validationState ? validationState === "invalid" : groupContext?.isInvalid ?? false, isIndeterminate = false, + validationBehavior = groupContext?.validationBehavior ?? "aria", defaultSelected, classNames, className, @@ -145,6 +146,7 @@ export function useCheckbox(props: UseCheckboxProps = {}) { children, autoFocus, defaultSelected, + validationBehavior, isIndeterminate, isRequired, isInvalid, @@ -167,6 +169,7 @@ export function useCheckbox(props: UseCheckboxProps = {}) { isReadOnlyProp, isSelectedProp, defaultSelected, + validationBehavior, otherProps["aria-label"], otherProps["aria-labelledby"], onValueChange, @@ -182,22 +185,9 @@ export function useCheckbox(props: UseCheckboxProps = {}) { isPressed: isPressedKeyboard, } = isInGroup ? // eslint-disable-next-line - useReactAriaCheckboxGroupItem( - { - ...ariaCheckboxProps, - isInvalid, - validationBehavior: "native", - }, - groupContext.groupState, - inputRef, - ) + useReactAriaCheckboxGroupItem({...ariaCheckboxProps}, groupContext.groupState, inputRef) : // eslint-disable-next-line - useReactAriaCheckbox( - {...ariaCheckboxProps, validationBehavior: "native"}, - // eslint-disable-next-line - toggleState, - inputRef, - ); + useReactAriaCheckbox({...ariaCheckboxProps}, toggleState, inputRef); const isInteractionDisabled = isDisabled || isReadOnly; @@ -220,10 +210,6 @@ export function useCheckbox(props: UseCheckboxProps = {}) { const pressed = isInteractionDisabled ? false : isPressed || isPressedKeyboard; - if (isRequired) { - inputProps.required = true; - } - const {hoverProps, isHovered} = useHover({ isDisabled: inputProps.disabled, }); diff --git a/packages/components/checkbox/stories/checkbox-group.stories.tsx b/packages/components/checkbox/stories/checkbox-group.stories.tsx index 131e8ee342..34ad88f04b 100644 --- a/packages/components/checkbox/stories/checkbox-group.stories.tsx +++ b/packages/components/checkbox/stories/checkbox-group.stories.tsx @@ -39,6 +39,12 @@ export default { type: "boolean", }, }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, }, } as Meta; diff --git a/packages/components/checkbox/stories/checkbox.stories.tsx b/packages/components/checkbox/stories/checkbox.stories.tsx index abd840770b..aa60c33c0d 100644 --- a/packages/components/checkbox/stories/checkbox.stories.tsx +++ b/packages/components/checkbox/stories/checkbox.stories.tsx @@ -39,6 +39,12 @@ export default { type: "boolean", }, }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, }, } as Meta; @@ -71,8 +77,14 @@ const FormTemplate = (args: CheckboxProps) => {
{ - alert(`Submitted value: ${e.target["check"].value}`); e.preventDefault(); + const checkbox = e.target["check"] as HTMLInputElement; + + if (checkbox.checked) { + alert(`Submitted value: ${checkbox.value}`); + } else { + alert("Checkbox is not checked"); + } }} > diff --git a/packages/components/date-input/__tests__/date-input.test.tsx b/packages/components/date-input/__tests__/date-input.test.tsx index aa48657e87..62dd7d6bbf 100644 --- a/packages/components/date-input/__tests__/date-input.test.tsx +++ b/packages/components/date-input/__tests__/date-input.test.tsx @@ -63,6 +63,7 @@ describe("DateInput", () => { date.compare(new CalendarDate(1980, 1, 8)) <= 0 ); }} + name="date" />, ); @@ -70,9 +71,7 @@ describe("DateInput", () => { await user.tab(); }); - await act(async () => { - await user.keyboard("01011980"); - }); + await user.keyboard("01011980"); expect(tree.getByText("Date unavailable.")).toBeInTheDocument(); }); diff --git a/packages/components/date-input/src/date-input-group.tsx b/packages/components/date-input/src/date-input-group.tsx index f87c2b731d..cecd813fdf 100644 --- a/packages/components/date-input/src/date-input-group.tsx +++ b/packages/components/date-input/src/date-input-group.tsx @@ -75,7 +75,7 @@ export const DateInputGroup = forwardRef<"div", DateInputGroupProps>((props, ref return (
- {errorMessage ? ( + {isInvalid && errorMessage ? (
{errorMessage}
) : description ? (
{description}
diff --git a/packages/components/date-input/src/use-date-input.ts b/packages/components/date-input/src/use-date-input.ts index 49bdb2e93d..1ecbe7d8e4 100644 --- a/packages/components/date-input/src/use-date-input.ts +++ b/packages/components/date-input/src/use-date-input.ts @@ -132,7 +132,7 @@ export function useDateInput(originalProps: UseDateInputPro fieldProps: fieldPropsProp, errorMessageProps: errorMessagePropsProp, descriptionProps: descriptionPropsProp, - validationBehavior, + validationBehavior = globalContext?.validationBehavior ?? "aria", shouldForceLeadingZeros = true, minValue = globalContext?.defaultDates?.minDate ?? new CalendarDate(1900, 1, 1), maxValue = globalContext?.defaultDates?.maxDate ?? new CalendarDate(2099, 12, 31), diff --git a/packages/components/date-input/src/use-time-input.ts b/packages/components/date-input/src/use-time-input.ts index c4748a3b7c..5adfe588d7 100644 --- a/packages/components/date-input/src/use-time-input.ts +++ b/packages/components/date-input/src/use-time-input.ts @@ -70,7 +70,7 @@ interface Props extends NextUIBaseProps { export type UseTimeInputProps = Props & DateInputVariantProps & - Omit, "validationBehavior">; + AriaTimeFieldProps; export function useTimeInput(originalProps: UseTimeInputProps) { const globalContext = useProviderContext(); @@ -93,7 +93,7 @@ export function useTimeInput(originalProps: UseTimeInputPro fieldProps: fieldPropsProp, errorMessageProps: errorMessagePropsProp, descriptionProps: descriptionPropsProp, - // validationBehavior = "native", TODO: Uncomment this one we support `native` and `aria` validations + validationBehavior = globalContext?.validationBehavior ?? "aria", shouldForceLeadingZeros = true, minValue, maxValue, @@ -114,7 +114,7 @@ export function useTimeInput(originalProps: UseTimeInputPro locale, minValue, maxValue, - + validationBehavior, isInvalid: isInvalidProp, shouldForceLeadingZeros, }); @@ -128,11 +128,7 @@ export function useTimeInput(originalProps: UseTimeInputPro descriptionProps, errorMessageProps, isInvalid: ariaIsInvalid, - } = useAriaTimeField( - {...originalProps, label, validationBehavior: "native", inputRef}, - state, - domRef, - ); + } = useAriaTimeField({...originalProps, label, validationBehavior, inputRef}, state, domRef); const baseStyles = clsx(classNames?.base, className); diff --git a/packages/components/date-input/stories/date-input.stories.tsx b/packages/components/date-input/stories/date-input.stories.tsx index a23e4e211b..87c2b74697 100644 --- a/packages/components/date-input/stories/date-input.stories.tsx +++ b/packages/components/date-input/stories/date-input.stories.tsx @@ -1,6 +1,6 @@ import React from "react"; import {Meta} from "@storybook/react"; -import {dateInput} from "@nextui-org/theme"; +import {dateInput, button} from "@nextui-org/theme"; import { CalendarDate, DateValue, @@ -55,6 +55,12 @@ export default { type: "boolean", }, }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, }, } as Meta; @@ -67,6 +73,21 @@ const Template = (args: DateInputProps) => ( ); +const FormTemplate = (args: DateInputProps) => ( + { + e.preventDefault(); + alert(`Submitted: ${e.target["date"].value}`); + }} + > + + + +); + const LabelPlacementTemplate = (args: DateInputProps) => (
@@ -152,7 +173,7 @@ export const Default = { }; export const Required = { - render: Template, + render: FormTemplate, args: { ...defaultProps, isRequired: true, @@ -337,3 +358,35 @@ export const HourCycle = { granularity: "minute", }, }; + +export const UnavailableDates = { + render: FormTemplate, + + args: { + ...defaultProps, + label: "Appointment date (Unavailable: Jan 1 - Jan 8, 2024)", + isDateUnavailable: (date) => { + return ( + date.compare(new CalendarDate(2024, 1, 1)) >= 0 && + date.compare(new CalendarDate(2024, 1, 8)) <= 0 + ); + }, + }, +}; + +export const WithValidation = { + render: FormTemplate, + + args: { + ...defaultProps, + validate: (value) => { + if (!value) { + return "Please enter a date"; + } + if (value.year < 2024) { + return "Please select a date in the year 2024 or later"; + } + }, + label: "Date (Year 2024 or later)", + }, +}; diff --git a/packages/components/date-input/stories/time-input.stories.tsx b/packages/components/date-input/stories/time-input.stories.tsx index 86ba657bdc..16f0190bf2 100644 --- a/packages/components/date-input/stories/time-input.stories.tsx +++ b/packages/components/date-input/stories/time-input.stories.tsx @@ -1,6 +1,6 @@ import React from "react"; import {Meta} from "@storybook/react"; -import {dateInput} from "@nextui-org/theme"; +import {dateInput, button} from "@nextui-org/theme"; import {ClockCircleLinearIcon} from "@nextui-org/shared-icons"; import { parseAbsoluteToLocal, @@ -51,6 +51,12 @@ export default { type: "boolean", }, }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, }, } as Meta; @@ -61,12 +67,20 @@ const defaultProps = { const Template = (args: TimeInputProps) => ; -export const Default = { - render: Template, - args: { - ...defaultProps, - }, -}; +const FormTemplate = (args: TimeInputProps) => ( +
{ + e.preventDefault(); + alert(`Submitted: ${e.target["time"].value}`); + }} + > + + + +); const LabelPlacementTemplate = (args: TimeInputProps) => (
@@ -126,8 +140,15 @@ const GranularityTemplate = (args: TimeInputProps) => { ); }; -export const Required = { +export const Default = { render: Template, + args: { + ...defaultProps, + }, +}; + +export const Required = { + render: FormTemplate, args: { ...defaultProps, isRequired: true, @@ -282,3 +303,19 @@ export const HourCycle = { granularity: "minute", }, }; +export const WithValidation = { + render: FormTemplate, + + args: { + ...defaultProps, + validate: (value) => { + if (!value) { + return "Please enter a time"; + } + if (value.hour < 9) { + return "Please select a time at 9 A.M. or later"; + } + }, + label: "Time (9 A.M. or later)", + }, +}; diff --git a/packages/components/date-picker/src/date-range-picker-field.tsx b/packages/components/date-picker/src/date-range-picker-field.tsx index 7e9ee28c53..dc9119532c 100644 --- a/packages/components/date-picker/src/date-range-picker-field.tsx +++ b/packages/components/date-picker/src/date-range-picker-field.tsx @@ -44,7 +44,6 @@ function DateRangePickerField( let state = useDateFieldState({ ...otherProps, locale, - validationBehavior: "native", createCalendar: !createCalendarProp || typeof createCalendarProp !== "function" ? createCalendar @@ -74,7 +73,7 @@ function DateRangePickerField( state={state} /> ))} - + ); } diff --git a/packages/components/date-picker/src/use-date-picker-base.ts b/packages/components/date-picker/src/use-date-picker-base.ts index bf058cd214..7733b82556 100644 --- a/packages/components/date-picker/src/use-date-picker-base.ts +++ b/packages/components/date-picker/src/use-date-picker-base.ts @@ -7,12 +7,12 @@ import type {PopoverProps} from "@nextui-org/popover"; import type {ReactNode} from "react"; import type {ValueBase} from "@react-types/shared"; +import {dataAttr} from "@nextui-org/shared-utils"; import {dateInput, DatePickerVariantProps} from "@nextui-org/theme"; import {useState} from "react"; import {HTMLNextUIProps, mapPropsVariants, useProviderContext} from "@nextui-org/system"; import {mergeProps} from "@react-aria/utils"; import {useDOMRef} from "@nextui-org/react-utils"; -import {dataAttr} from "@nextui-org/shared-utils"; import {useLocalizedStringFormatter} from "@react-aria/i18n"; import intlMessages from "../intl/messages"; @@ -109,7 +109,7 @@ export type UseDatePickerBaseProps = Props & DateInputProps, Variants | "ref" | "createCalendar" | "startContent" | "endContent" | "inputRef" > & - Omit, keyof ValueBase | "validate" | "validationBehavior">; + Omit, keyof ValueBase | "validate">; export function useDatePickerBase(originalProps: UseDatePickerBaseProps) { const globalContext = useProviderContext(); @@ -130,7 +130,7 @@ export function useDatePickerBase(originalProps: UseDatePic description, startContent, validationState, - // validationBehavior, TODO: Uncomment this one we support `native` and `aria` validations + validationBehavior, visibleMonths = 1, pageBehavior = "visible", calendarWidth = 256, @@ -213,6 +213,7 @@ export function useDatePickerBase(originalProps: UseDatePic shouldForceLeadingZeros, isInvalid, errorMessage, + validationBehavior, "data-invalid": dataAttr(originalProps?.isInvalid), } as DateInputProps; @@ -224,6 +225,7 @@ export function useDatePickerBase(originalProps: UseDatePic placeholderValue: timePlaceholder, hourCycle: props.hourCycle, hideTimeZone: props.hideTimeZone, + validationBehavior, } as TimeInputProps; const popoverProps: PopoverProps = { diff --git a/packages/components/date-picker/src/use-date-picker.ts b/packages/components/date-picker/src/use-date-picker.ts index fa5b97bfcf..8aa37d2ec0 100644 --- a/packages/components/date-picker/src/use-date-picker.ts +++ b/packages/components/date-picker/src/use-date-picker.ts @@ -8,6 +8,7 @@ import type {UseDatePickerBaseProps} from "./use-date-picker-base"; import type {DOMAttributes} from "@nextui-org/system"; import type {DatePickerSlots, SlotsToClasses} from "@nextui-org/theme"; +import {useProviderContext} from "@nextui-org/system"; import {useMemo} from "react"; import {datePicker} from "@nextui-org/theme"; import {useDatePickerState} from "@react-stately/datepicker"; @@ -54,6 +55,11 @@ export function useDatePicker({ classNames, ...originalProps }: UseDatePickerProps) { + const globalContext = useProviderContext(); + + const validationBehavior = + originalProps.validationBehavior ?? globalContext?.validationBehavior ?? "aria"; + const { domRef, endContent, @@ -74,10 +80,11 @@ export function useDatePicker({ userTimeInputProps, selectorButtonProps, selectorIconProps, - } = useDatePickerBase(originalProps); + } = useDatePickerBase({...originalProps, validationBehavior}); let state: DatePickerState = useDatePickerState({ ...originalProps, + validationBehavior, shouldCloseOnSelect: () => !state.hasTime, }); @@ -101,7 +108,7 @@ export function useDatePicker({ calendarProps: ariaCalendarProps, descriptionProps, errorMessageProps, - } = useAriaDatePicker(originalProps, state, domRef); + } = useAriaDatePicker({...originalProps, validationBehavior}, state, domRef); // Time field values originalProps.maxValue && "hour" in originalProps.maxValue ? originalProps.maxValue : null; 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 4df77b48f6..334f213ff1 100644 --- a/packages/components/date-picker/src/use-date-range-picker.ts +++ b/packages/components/date-picker/src/use-date-range-picker.ts @@ -14,6 +14,7 @@ import type {DateInputGroupProps} from "@nextui-org/date-input"; import type {DateRangePickerSlots, SlotsToClasses} from "@nextui-org/theme"; import type {DateInputProps} from "@nextui-org/date-input"; +import {useProviderContext} from "@nextui-org/system"; import {useMemo, useRef} from "react"; import {useDateRangePickerState} from "@react-stately/datepicker"; import {useDateRangePicker as useAriaDateRangePicker} from "@react-aria/datepicker"; @@ -57,7 +58,7 @@ export type UseDateRangePickerProps = Props & AriaDateRa export function useDateRangePicker({ as, - isInvalid, + isInvalid: isInvalidProp, description, startContent, endContent, @@ -67,6 +68,11 @@ export function useDateRangePicker({ classNames, ...originalProps }: UseDateRangePickerProps) { + const globalContext = useProviderContext(); + + const validationBehavior = + originalProps.validationBehavior ?? globalContext?.validationBehavior ?? "aria"; + const { domRef, slotsProps, @@ -86,10 +92,11 @@ export function useDateRangePicker({ hasMultipleMonths, selectorButtonProps, selectorIconProps, - } = useDatePickerBase(originalProps); + } = useDatePickerBase({...originalProps, validationBehavior}); let state: DateRangePickerState = useDateRangePickerState({ ...originalProps, + validationBehavior, shouldCloseOnSelect: () => !state.hasTime, }); @@ -107,7 +114,10 @@ export function useDateRangePicker({ validationErrors, descriptionProps, errorMessageProps, - } = useAriaDateRangePicker(originalProps, state, domRef); + isInvalid: isAriaInvalid, + } = useAriaDateRangePicker({...originalProps, validationBehavior}, state, domRef); + + const isInvalid = isInvalidProp || isAriaInvalid; const slots = useMemo( () => diff --git a/packages/components/date-picker/stories/date-picker.stories.tsx b/packages/components/date-picker/stories/date-picker.stories.tsx index 7f54cf00a0..1dac4ee286 100644 --- a/packages/components/date-picker/stories/date-picker.stories.tsx +++ b/packages/components/date-picker/stories/date-picker.stories.tsx @@ -1,6 +1,6 @@ import React from "react"; import {Meta} from "@storybook/react"; -import {dateInput} from "@nextui-org/theme"; +import {dateInput, button} from "@nextui-org/theme"; import { DateValue, getLocalTimeZone, @@ -59,6 +59,12 @@ export default { type: "boolean", }, }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, }, decorators: [ (Story) => ( @@ -77,6 +83,21 @@ const defaultProps = { const Template = (args: DatePickerProps) => ; +const FormTemplate = (args: DatePickerProps) => ( +
{ + e.preventDefault(); + alert(`Submitted: ${e.target["date"].value}`); + }} + > + + + +); + const LabelPlacementTemplate = (args: DatePickerProps) => (
@@ -331,7 +352,7 @@ export const Controlled = { }; export const Required = { - render: Template, + render: FormTemplate, args: { ...defaultProps, isRequired: true, @@ -499,3 +520,20 @@ export const Presets = { ...defaultProps, }, }; + +export const WithValidation = { + render: FormTemplate, + + args: { + ...defaultProps, + validate: (value) => { + if (!value) { + return "Please enter a date"; + } + if (value.year < 2024) { + return "Please select a date in the year 2024 or later"; + } + }, + label: "Date (Year 2024 or later)", + }, +}; 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 3bdd30c97e..f24d1d4385 100644 --- a/packages/components/date-picker/stories/date-range-picker.stories.tsx +++ b/packages/components/date-picker/stories/date-range-picker.stories.tsx @@ -1,6 +1,6 @@ import React from "react"; import {Meta} from "@storybook/react"; -import {dateInput} from "@nextui-org/theme"; +import {dateInput, button} from "@nextui-org/theme"; import { endOfMonth, endOfWeek, @@ -61,6 +61,12 @@ export default { type: "boolean", }, }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, }, decorators: [ (Story) => ( @@ -78,6 +84,23 @@ const defaultProps = { const Template = (args: DateRangePickerProps) => ; +const FormTemplate = (args: DateRangePickerProps) => ( +
{ + e.preventDefault(); + alert( + `Submitted: start -> ${e.target["start-date"].value} end -> ${e.target["end-date"].value}`, + ); + }} + > + + + +); + const LabelPlacementTemplate = (args: DateRangePickerProps) => (
@@ -398,7 +421,7 @@ export const Controlled = { }; export const Required = { - render: Template, + render: FormTemplate, args: { ...defaultProps, isRequired: true, @@ -579,3 +602,22 @@ export const Presets = { visibleMonths: 2, }, }; + +export const WithValidation = { + render: FormTemplate, + + args: { + ...defaultProps, + validate: (value) => { + if (!value || !value.start || !value.end) { + return "Please enter a valid date range"; + } + const {start, end} = value; + + if (start.year < 2024 || end.year < 2024) { + return "Both start and end dates must be in the year 2024 or later"; + } + }, + label: "Date Range (Year 2024 or later)", + }, +}; diff --git a/packages/components/input/__tests__/input.test.tsx b/packages/components/input/__tests__/input.test.tsx index a6da5128f4..13de8cf841 100644 --- a/packages/components/input/__tests__/input.test.tsx +++ b/packages/components/input/__tests__/input.test.tsx @@ -37,10 +37,17 @@ describe("Input", () => { expect(container.querySelector("input")).toHaveAttribute("disabled"); }); - it("should have required attribute when isRequired", () => { - const {container} = render(); + it("should have required attribute when isRequired with native validationBehavior", () => { + const {container} = render(); expect(container.querySelector("input")).toHaveAttribute("required"); + expect(container.querySelector("input")).not.toHaveAttribute("aria-required"); + }); + + it("should have aria-required attribute when isRequired with aria validationBehavior", () => { + const {container} = render(); + + expect(container.querySelector("input")).not.toHaveAttribute("required"); expect(container.querySelector("input")).toHaveAttribute("aria-required", "true"); }); diff --git a/packages/components/input/src/use-input.ts b/packages/components/input/src/use-input.ts index 11ead2b461..4902d3279c 100644 --- a/packages/components/input/src/use-input.ts +++ b/packages/components/input/src/use-input.ts @@ -86,7 +86,7 @@ export interface Props["autoCapitalize"]; export type UseInputProps = - Props & Omit & InputVariantProps; + Props & Omit & InputVariantProps; export function useInput( originalProps: UseInputProps, @@ -111,6 +111,7 @@ export function useInput {}, ...otherProps @@ -174,13 +175,13 @@ export function useInput ( diff --git a/packages/components/input/stories/textarea.stories.tsx b/packages/components/input/stories/textarea.stories.tsx index 9c75c2445d..fc0799be94 100644 --- a/packages/components/input/stories/textarea.stories.tsx +++ b/packages/components/input/stories/textarea.stories.tsx @@ -52,6 +52,12 @@ export default { type: "boolean", }, }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, }, decorators: [ (Story) => ( diff --git a/packages/components/radio/__tests__/radio.test.tsx b/packages/components/radio/__tests__/radio.test.tsx index d88c87a5ab..b89b2b7963 100644 --- a/packages/components/radio/__tests__/radio.test.tsx +++ b/packages/components/radio/__tests__/radio.test.tsx @@ -146,7 +146,7 @@ describe("Radio", () => { it('should work correctly with "isRequired" prop', () => { const {getByRole, getAllByRole} = render( - + Option 1 Option 2 , @@ -204,11 +204,11 @@ describe("validation", () => { beforeAll(() => { user = userEvent.setup(); }); - describe("validationBehavior=native (default)", () => { + describe("validationBehavior=native", () => { it("supports isRequired", async () => { const {getAllByRole, getByRole, getByTestId} = render(
- + Dogs Cats Dragons @@ -246,4 +246,39 @@ describe("validation", () => { expect(group).not.toHaveAttribute("aria-describedby"); }); }); + + describe("validationBehavior=aria", () => { + it("supports validate function", async () => { + const {getAllByRole, getByRole} = render( + (v === "dragons" ? "Too scary" : null)} + validationBehavior="aria" + > + Dogs + Cats + Dragons + , + ); + + const group = getByRole("radiogroup"); + + expect(group).toHaveAttribute("aria-describedby"); + expect(group).toHaveAttribute("aria-invalid", "true"); + expect( + document.getElementById(group.getAttribute("aria-describedby") as string), + ).toHaveTextContent("Too scary"); + + const radios = getAllByRole("radio") as HTMLInputElement[]; + + for (let input of radios) { + expect(input.validity.valid).toBe(true); + } + + await user.click(radios[0]); + expect(group).not.toHaveAttribute("aria-describedby"); + expect(group).not.toHaveAttribute("aria-invalid"); + }); + }); }); diff --git a/packages/components/radio/src/use-radio-group.ts b/packages/components/radio/src/use-radio-group.ts index 4c574ed83f..da6ce27a2b 100644 --- a/packages/components/radio/src/use-radio-group.ts +++ b/packages/components/radio/src/use-radio-group.ts @@ -47,7 +47,7 @@ interface Props extends Omit, "onChange"> { } export type UseRadioGroupProps = Omit & - Omit & + Omit & Partial>; export type ContextType = { @@ -74,6 +74,7 @@ export function useRadioGroup(props: UseRadioGroupProps) { name, isInvalid: isInvalidProp, validationState, + validationBehavior = globalContext?.validationBehavior ?? "aria", size = "md", color = "primary", isDisabled = false, @@ -104,7 +105,7 @@ export function useRadioGroup(props: UseRadioGroupProps) { isReadOnly, isInvalid: validationState === "invalid" || isInvalidProp, orientation, - validationBehavior: "native", + validationBehavior, onChange: onValueChange, }; }, [ @@ -116,6 +117,7 @@ export function useRadioGroup(props: UseRadioGroupProps) { isReadOnly, isInvalidProp, validationState, + validationBehavior, orientation, onValueChange, ]); @@ -132,7 +134,7 @@ export function useRadioGroup(props: UseRadioGroupProps) { validationDetails, } = useReactAriaRadioGroup(otherPropsWithOrientation, groupState); - const isInvalid = otherPropsWithOrientation.isInvalid || isAriaInvalid; + const isInvalid = otherPropsWithOrientation.isInvalid || isAriaInvalid || groupState.isInvalid; const context: ContextType = useMemo( () => ({ @@ -154,11 +156,11 @@ export function useRadioGroup(props: UseRadioGroupProps) { onChange, disableAnimation, groupState.name, - groupState?.isDisabled, - groupState?.isReadOnly, - groupState?.isRequired, - groupState?.selectedValue, - groupState?.lastFocusedValue, + groupState.isDisabled, + groupState.isReadOnly, + groupState.isRequired, + groupState.selectedValue, + groupState.lastFocusedValue, ], ); diff --git a/packages/components/radio/stories/radio.stories.tsx b/packages/components/radio/stories/radio.stories.tsx index b2631f617c..a279d61fbd 100644 --- a/packages/components/radio/stories/radio.stories.tsx +++ b/packages/components/radio/stories/radio.stories.tsx @@ -37,6 +37,12 @@ export default { type: "boolean", }, }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, }, } as Meta; diff --git a/packages/components/select/src/use-select.ts b/packages/components/select/src/use-select.ts index cd837a1250..6e1cf07d11 100644 --- a/packages/components/select/src/use-select.ts +++ b/packages/components/select/src/use-select.ts @@ -230,8 +230,8 @@ export function useSelect(originalProps: UseSelectProps) { selectionMode, disallowEmptySelection, children: children as CollectionChildren, - isRequired: originalProps?.isRequired, - isDisabled: originalProps?.isDisabled, + isRequired: originalProps.isRequired, + isDisabled: originalProps.isDisabled, defaultOpen, onOpenChange: (open) => { onOpenChange?.(open); @@ -257,7 +257,7 @@ export function useSelect(originalProps: UseSelectProps) { state = { ...state, - ...(originalProps?.isDisabled && { + ...(originalProps.isDisabled && { disabledKeys: new Set([...state.collection.getKeys()]), }), }; @@ -282,7 +282,7 @@ export function useSelect(originalProps: UseSelectProps) { validationErrors, validationDetails, } = useMultiSelect( - {...props, disallowEmptySelection, isDisabled: originalProps?.isDisabled}, + {...props, disallowEmptySelection, isDisabled: originalProps.isDisabled}, state, triggerRef, ); @@ -292,7 +292,7 @@ export function useSelect(originalProps: UseSelectProps) { const {isPressed, buttonProps} = useAriaButton(triggerProps, triggerRef); const {focusProps, isFocused, isFocusVisible} = useFocusRing(); - const {isHovered, hoverProps} = useHover({isDisabled: originalProps?.isDisabled}); + const {isHovered, hoverProps} = useHover({isDisabled: originalProps.isDisabled}); const labelPlacement = useMemo(() => { if ((!originalProps.labelPlacement || originalProps.labelPlacement === "inside") && !label) { @@ -624,6 +624,7 @@ export function useSelect(originalProps: UseSelectProps) { isDisabled: originalProps?.isDisabled, isRequired: originalProps?.isRequired, name: originalProps?.name, + // TODO: Future enhancement to support "aria" validation behavior. validationBehavior: "native", }); diff --git a/packages/core/system/src/provider-context.ts b/packages/core/system/src/provider-context.ts index 3d1692d5f5..575b545999 100644 --- a/packages/core/system/src/provider-context.ts +++ b/packages/core/system/src/provider-context.ts @@ -17,6 +17,15 @@ export type ProviderContextProps = { * @default false */ disableRipple?: boolean; + /** + * Whether to use native HTML form validation to prevent form submission + * when the value is missing or invalid, or mark the field as required + * or invalid via ARIA. + * @see https://react-spectrum.adobe.com/react-aria/forms.html + * + * @default "aria" + */ + validationBehavior?: "aria" | "native"; /** * The default dates range that can be selected in the calendar. */ @@ -25,7 +34,7 @@ export type ProviderContextProps = { * The minimum date that can be selected in the calendar. * @see https://react-spectrum.adobe.com/internationalized/date/CalendarDate.html * - * @default new CalendarDate(1900, 1, 1) + * @default CalendarDate(1900, 1, 1) * */ minDate?: CalendarDate; diff --git a/packages/core/system/src/provider.tsx b/packages/core/system/src/provider.tsx index f62ec7b493..fa648c1a42 100644 --- a/packages/core/system/src/provider.tsx +++ b/packages/core/system/src/provider.tsx @@ -40,6 +40,7 @@ export const NextUIProvider: React.FC = ({ disableAnimation = false, disableRipple = false, skipFramerMotionAnimations = disableAnimation, + validationBehavior = "aria", locale = "en-US", defaultDates = { minDate: new CalendarDate(1900, 1, 1), @@ -64,6 +65,7 @@ export const NextUIProvider: React.FC = ({ defaultDates, disableAnimation, disableRipple, + validationBehavior, }; }, [ createCalendar, @@ -71,6 +73,7 @@ export const NextUIProvider: React.FC = ({ defaultDates?.minDate, disableAnimation, disableRipple, + validationBehavior, ]); return ( diff --git a/packages/hooks/use-aria-multiselect/src/use-multiselect-state.ts b/packages/hooks/use-aria-multiselect/src/use-multiselect-state.ts index aec0c3468d..8e2d5535c6 100644 --- a/packages/hooks/use-aria-multiselect/src/use-multiselect-state.ts +++ b/packages/hooks/use-aria-multiselect/src/use-multiselect-state.ts @@ -76,6 +76,7 @@ export function useMultiSelectState(props: MultiSelectProps): M const validationState = useFormValidationState({ ...props, + // TODO: Future enhancement to support "aria" validation behavior. validationBehavior: "native", // @ts-ignore value: listState.selectedKeys,