Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(select accessibility): add <SingleSelectA11y/> component #1620

Open
wants to merge 39 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1889bf5
chore: fix linter issues
Mohammer5 Oct 16, 2024
930efa8
feat(input): allow aria-label attribute
Mohammer5 Oct 16, 2024
01f5fa3
feat(select a11y): implement <SingleSelectA11y/>
Mohammer5 Oct 16, 2024
c33e8c4
docs(select a11y): add production stories for <SingleSelectA11y/>
Mohammer5 Oct 16, 2024
c5dc266
chore(select a11y): add translations file
Mohammer5 Oct 16, 2024
a1bea03
test(select a11y): add tests (cypress & jest) for <SingleSelectA11y/>
Mohammer5 Oct 16, 2024
3d5021f
chore(storybook): use prod stories only when starting yarn from a wor…
Mohammer5 Oct 17, 2024
285fa70
feat(select a11y): add some keyboard handling
Mohammer5 Oct 17, 2024
fa49321
chore: fix linter issues
Mohammer5 Oct 17, 2024
3266f86
feat(select a11y): implement typing while menu is open
Mohammer5 Oct 17, 2024
31285c6
feat(select a11y): scroll highlighted option into view
Mohammer5 Oct 18, 2024
7903410
feat(select a11y): handle pageUp and pageDown keys
Mohammer5 Oct 21, 2024
b36fb34
chore(select a11y): add empty test cases with @TODO comment
Mohammer5 Oct 21, 2024
5c87095
test(select a11y): add pageUp/pageDown tests (cypress) for <SingleSel…
Mohammer5 Oct 22, 2024
577375d
chore(select a11y): make position cucumber test into a vanilla cypres…
Mohammer5 Oct 23, 2024
4f412a7
fix(select a11y): make PageDown keypress respect disabled options
Mohammer5 Oct 23, 2024
50c6755
fix(select a11y): make PageUp keypress respect disabled options
Mohammer5 Oct 23, 2024
76fa698
fix(select a11y): make remaining keypress logic respect disabled options
Mohammer5 Oct 28, 2024
ed14df7
test(select a11y): cover keyboard interactions + disabled with tests
Mohammer5 Oct 28, 2024
5875efd
chore(select a11y): add comment to all props and remove superfluous p…
Mohammer5 Oct 28, 2024
ee3ab42
feat(select a11y): allow to specify custom option generally
Mohammer5 Oct 28, 2024
583ca45
feat(select a11y): allow to specify aria-busy update strategy
Mohammer5 Oct 28, 2024
4cce702
fix: handle keyboard input on filter input; + code org cleanup
Mohammer5 Nov 4, 2024
fe8496f
feat(ui collection): export SingleSelectA11y
Mohammer5 Nov 4, 2024
11b227e
chore(select a11y): update API.md
Mohammer5 Nov 4, 2024
e6a8833
feat(select a11y): add onEndReached prop
Mohammer5 Nov 4, 2024
c0d0d09
fix(select a11y): handle focussed option index separately when filtering
Mohammer5 Nov 4, 2024
1673079
fix(select a11y): start writing recipes & fix things found along the way
Mohammer5 Nov 6, 2024
0253c74
refactor(select a11y): address post-rebase issues
Mohammer5 Nov 7, 2024
51c1c60
feat(select a11y): close menu when tabbing
Mohammer5 Nov 7, 2024
88b7907
docs(select a11y): complete server-side filtering example
Mohammer5 Nov 10, 2024
26a8719
chore(select a11y server side filtering recipe): correct spelling mis…
Mohammer5 Nov 10, 2024
87e444c
fix(select a11y): give loading indicator space when no options
Mohammer5 Nov 10, 2024
9976be3
chore(select a11y): add TS types
Mohammer5 Nov 10, 2024
0ae24f6
docs(select): add SingleSelectA11y component to select components docs
Mohammer5 Nov 18, 2024
4debf99
feat(select a11y): add <SingleSelectA11yField/>
Mohammer5 Nov 19, 2024
8e1152b
feat(select a11y): add <SingleSelectA11yFieldFF/>
Mohammer5 Nov 19, 2024
cca9228
chore: add updated API.md files
Mohammer5 Nov 19, 2024
356bd4f
fix(select a11y): handle remaining TODO comments & rename internal co…
Mohammer5 Nov 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions collections/forms/i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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-09-26T14:15:11.940Z\n"
"PO-Revision-Date: 2024-09-26T14:15:11.941Z\n"
"POT-Creation-Date: 2024-11-28T08:22:51.040Z\n"
"PO-Revision-Date: 2024-11-28T08:22:51.041Z\n"

msgid "Upload file"
msgstr "Upload file"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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 (
<SingleSelectA11yField
{...rest}
name={input.name}
error={hasError(meta, error)}
valid={isValid(meta, valid, showValidStatus)}
loading={isLoading(meta, loading, showLoadingStatus)}
validationText={getValidationText(meta, validationText, error)}
onFocus={createFocusHandler(input, onFocus)}
onChange={(value) => input.onChange(value)}
onBlur={createBlurHandler(input, onBlur)}
value={input.value || ''}
/>
)
}

SingleSelectA11yFieldFF.propTypes = {
/** `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,

/** 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),

/** Allows to override what's rendered inside the `button[role="option"]`.
* Can be overriden on an individual option basis **/
optionComponent: PropTypes.elementType,

/** 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,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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 \`<Form>\` component, these 'FF' UI components are intended to be used in the \`component\` prop of the [Final Form \`<Field>\` components](https://final-form.org/docs/react-final-form/api/Field) where they will receive some props from the Field, e.g. \`<Field component={SingleSelectA11yFieldFF} />\`. 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 = () => (
<Field
required
component={SingleSelectA11yFieldFF}
name="story"
label="Do you agree?"
options={options}
validate={hasValue}
/>
)

export const InitialValue = () => (
<Field
component={SingleSelectA11yFieldFF}
name="story"
label="Do you agree?"
options={options}
initialValue="4"
/>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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('<SingleSelectA11yFieldFF/>', () => {
it("should use FF's input for value selection", () => {
const onSubmit = jest.fn()

render(
<Form onSubmit={onSubmit}>
{(formRenderProps) => (
<form onSubmit={formRenderProps.handleSubmit}>
<Field
component={SingleSelectA11yFieldFF}
name="story"
label="Label text"
options={[
{ value: '', label: 'None' },
{ value: 'foo', label: 'Foo' },
{ value: 'bar', label: 'Bar' },
]}
/>

<Button primary type="submit">
Submit
</Button>
</form>
)}
</Form>
)

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(
<Form onSubmit={onSubmit}>
{(formRenderProps) => (
<form onSubmit={formRenderProps.handleSubmit}>
<Field
required
component={SingleSelectA11yFieldFF}
name="story"
label="Label text"
validate={hasValue}
options={[
{ value: '', label: 'None' },
{ value: 'foo', label: 'Foo' },
{ value: 'bar', label: 'Bar' },
]}
/>

<Button primary type="submit">
Submit
</Button>
</form>
)}
</Form>
)

fireEvent.click(screen.getByRole('button'))

const error = screen.getByText('Please provide a value')
expect(error).not.toBeNull()
})
})
Loading
Loading