diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot index 838734850..aedabd0ab 100644 --- a/collections/forms/i18n/en.pot +++ b/collections/forms/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-11-07T00:44:30.575Z\n" -"PO-Revision-Date: 2024-11-07T00:44:30.576Z\n" +"POT-Creation-Date: 2024-11-19T08:12:09.695Z\n" +"PO-Revision-Date: 2024-11-19T08:12:09.696Z\n" msgid "Upload file" msgstr "Upload file" diff --git a/collections/forms/src/SingleSelectA11yFieldFF/SingleSelectA11yFieldFF.js b/collections/forms/src/SingleSelectA11yFieldFF/SingleSelectA11yFieldFF.js new file mode 100644 index 000000000..364badd5f --- /dev/null +++ b/collections/forms/src/SingleSelectA11yFieldFF/SingleSelectA11yFieldFF.js @@ -0,0 +1,174 @@ +import { requiredIf } from '@dhis2/prop-types' +import { SingleSelectA11yField } from '@dhis2-ui/select' +import PropTypes from 'prop-types' +import React from 'react' +import { + createFocusHandler, + createBlurHandler, + hasError, + isLoading, + isValid, + getValidationText, +} from '../shared/helpers.js' +import { inputPropType, metaPropType } from '../shared/propTypes.js' + +export const SingleSelectA11yFieldFF = ({ + error, + input, + loading, + meta, + showLoadingStatus, + showValidStatus, + valid, + validationText, + onBlur, + onFocus, + ...rest +}) => { + return ( + input.onChange(value)} + onBlur={createBlurHandler(input, onBlur)} + value={input.value || ''} + /> + ) +} + +SingleSelectA11yFieldFF.propTypes = { + /** necessary for IDs that are required for accessibility **/ + idPrefix: PropTypes.string.isRequired, + + /** `input` props received from Final Form `Field` */ + input: inputPropType.isRequired, + + /** Label displayed above the input **/ + label: PropTypes.string.isRequired, + + /** `meta` props received from Final Form `Field` */ + meta: metaPropType.isRequired, + + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }) + ).isRequired, + + /** Will focus the select initially **/ + autoFocus: PropTypes.bool, + + /** Additional class names that will be applied to the root element **/ + className: PropTypes.string, + + /** This will allow us to put an aria-label on the clear button **/ + clearText: requiredIf((props) => props.clearable, PropTypes.string), + + /** Whether a clear button should be displayed or not **/ + clearable: PropTypes.bool, + + /** Allows to override what's rendered inside the `button[role="option"]`. + * Can be overriden on an individual option basis **/ + customOption: PropTypes.elementType, + + /** A value for a `data-test` attribute on the root element **/ + dataTest: PropTypes.string, + + /** Renders a select with lower height **/ + dense: PropTypes.bool, + + /** Disables all interactions with the select (except focussing) **/ + disabled: PropTypes.bool, + + /** Text or component to display when there are no options **/ + empty: PropTypes.node, + + /** Applies 'error' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props **/ + error: PropTypes.bool, + + /** Help text that will be displayed below the input **/ + filterHelpText: PropTypes.string, + + /** Value will be used as aria-label attribute on the filter input **/ + filterLabel: PropTypes.string, + + /** Placeholder for the filter input **/ + filterPlaceholder: PropTypes.string, + + /** Value of the filter input **/ + filterValue: PropTypes.string, + + /** Whether the select should display a filter input **/ + filterable: PropTypes.bool, + + /** Help text, displayed below the input **/ + helpText: PropTypes.string, + + /** Will show a loading indicator at the end of the options-list **/ + loading: PropTypes.bool, + + /** Text that will be displayed next to the loading indicator **/ + menuLoadingText: PropTypes.string, + + /** Allows to modify the max height of the menu **/ + menuMaxHeight: PropTypes.string, + + /** String that will be displayed when the select is being filtered but the options array is empty **/ + noMatchText: requiredIf((props) => props.filterable, PropTypes.string), + + /** For a11y: How aggressively the user should be updated about changes in options **/ + optionUpdateStrategy: PropTypes.oneOf(['off', 'polite', 'assertive']), + + /** String to show when there's no value and no valueLabel **/ + placeholder: PropTypes.string, + + /** String that will be displayed before the label of the selected option **/ + prefix: PropTypes.string, + + /** Whether a value is required or not **/ + required: PropTypes.bool, + + showLoadingStatus: PropTypes.bool, + + showValidStatus: PropTypes.bool, + + /** Standard HTML tab-index attribute that will be put on the combobox's root element **/ + tabIndex: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + + valid: PropTypes.bool, + + validationText: PropTypes.string, + + /** + * When the option is not in the options list (e.g. not loaded or list is + * filtered), but a selected value needs to be displayed, then this prop can + * be used to supply the text to be shown. + **/ + valueLabel: requiredIf((props) => { + if (props.options.find(({ value }) => props.value === value)) { + return false + } + + return props.value + }, PropTypes.string), + + /** Applies 'warning' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props **/ + warning: PropTypes.bool, + + /** Will be called when the combobox is loses focus **/ + onBlur: PropTypes.func, + + /** Will be called when the last option is scrolled into the visible area **/ + onEndReached: PropTypes.func, + + /** Will be called when the filter value changes **/ + onFilterChange: PropTypes.func, + + /** Will be called when the combobox is being focused **/ + onFocus: PropTypes.func, +} diff --git a/collections/forms/src/SingleSelectA11yFieldFF/SingleSelectA11yFieldFF.prod.stories.js b/collections/forms/src/SingleSelectA11yFieldFF/SingleSelectA11yFieldFF.prod.stories.js new file mode 100644 index 000000000..dd6cb2c9a --- /dev/null +++ b/collections/forms/src/SingleSelectA11yFieldFF/SingleSelectA11yFieldFF.prod.stories.js @@ -0,0 +1,81 @@ +import React from 'react' +import { Field } from 'react-final-form' +import { formDecorator } from '../formDecorator.js' +import { inputArgType, metaArgType } from '../shared/propTypes.js' +import { hasValue } from '../validators/index.js' +import { SingleSelectA11yFieldFF } from './SingleSelectA11yFieldFF.js' + +const description = ` +The \`SingleSelectA11yFieldFF\` is a wrapper around a \`SingleSelectA11yField\` that enables it to work with Final Form, the preferred library for form validation and utilities in DHIS 2 apps. + +#### Final Form + +See how to use Final Form at [Final Form - Getting Started](https://final-form.org/docs/react-final-form/getting-started). + +Inside a Final Form \`
\` component, these 'FF' UI components are intended to be used in the \`component\` prop of the [Final Form \`\` components](https://final-form.org/docs/react-final-form/api/Field) where they will receive some props from the Field, e.g. \`\`. See the code samples below for examples. + +#### Props + +The props shown in the table below are generally provided to the \`SingleSelectA11yFieldFF\` wrapper by the Final Form \`Field\`. + +Note that any props beyond the API of the \`Field\` component will be spread to the \`SingleSelectA11yFieldFF\`, which passes any extra props to the underlying \`SingleSelectA11yField\` using \`{...rest}\`. + +Therefore, to add any props to the \`SingleSelectA11yFieldFF\` or \`SingleSelectA11yField\`, add those props to the parent Final Form \`Field\` component. + +Also see \`SingleSelect\` and \`SingleSelectA11yField\` for notes about props and implementation. + +\`\`\`js +import { SingleSelectA11yFieldFF } from '@dhis2/ui' +\`\`\` + +Press **Submit** to see the form values logged to the console. + +_**Note:** Dropdowns may not appear correctly on this page. See the affected demos in the 'Canvas' tab for propper dropdown placement._ +` + +const options = [ + { value: '1', label: 'one' }, + { value: '2', label: 'two' }, + { value: '3', label: 'three' }, + { value: '4', label: 'four' }, + { value: '5', label: 'five' }, + { value: '6', label: 'six' }, + { value: '7', label: 'seven' }, + { value: '8', label: 'eight' }, + { value: '9', label: 'nine' }, + { value: '10', label: 'ten' }, +] + +export default { + title: 'SingleSelectA11yField (Final Form)', + component: SingleSelectA11yFieldFF, + decorators: [formDecorator], + parameters: { docs: { description: { component: description } } }, + argTypes: { + input: { ...inputArgType }, + meta: { ...metaArgType }, + }, +} + +export const Default = () => ( + +) + +export const InitialValue = () => ( + +) diff --git a/collections/forms/src/SingleSelectA11yFieldFF/SingleSelectA11yFieldFF.test.js b/collections/forms/src/SingleSelectA11yFieldFF/SingleSelectA11yFieldFF.test.js new file mode 100644 index 000000000..4959733e8 --- /dev/null +++ b/collections/forms/src/SingleSelectA11yFieldFF/SingleSelectA11yFieldFF.test.js @@ -0,0 +1,83 @@ +import '@testing-library/jest-dom' +import { Button } from '@dhis2-ui/button' +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Field, Form } from 'react-final-form' +import { hasValue } from '../validators/index.js' +import { SingleSelectA11yFieldFF } from './SingleSelectA11yFieldFF.js' + +describe('', () => { + it("should use FF's input for value selection", () => { + const onSubmit = jest.fn() + + render( + + {(formRenderProps) => ( + + + + + + )} + + ) + + fireEvent.click(screen.getByRole('combobox')) + fireEvent.click(screen.getByText('Foo').parentNode) + fireEvent.click(screen.getByRole('button')) + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenCalledWith( + { selectName: 'foo' }, + expect.anything(), + expect.anything() + ) + }) + + it('should display the validation error', () => { + const onSubmit = jest.fn() + + render( +
+ {(formRenderProps) => ( + + + + + + )} + + ) + + fireEvent.click(screen.getByRole('button')) + + const error = screen.getByText('Please provide a value') + expect(error).not.toBeNull() + }) +})