Skip to content

Commit

Permalink
STCOM-766 Accessibility | Repeatable Field Component (#2215)
Browse files Browse the repository at this point in the history
  • Loading branch information
vashjs authored Feb 2, 2024
1 parent b9105c1 commit 7532ec4
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 134 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* Format aria attributes in `<Icon>`. Refs STCOM-1165.
* Upgrade `stylelint` and fix errors. Refs STCOM-1087.
* Show action buttons in correct color. Refs STCOM-1256.
* Accessibility | Repeatable Field Component. Refs STCOM-766.

## [12.0.0](https://github.com/folio-org/stripes-components/tree/v12.0.0) (2023-10-11)
[Full Changelog](https://github.com/folio-org/stripes-components/compare/v11.0.0...v12.0.0)
Expand Down
1 change: 1 addition & 0 deletions hooks/usePrevious/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './usePrevious';
13 changes: 13 additions & 0 deletions hooks/usePrevious/usePrevious.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useEffect, useRef } from "react";

export const usePrevious = (value) => {
const ref = useRef();

useEffect(() => {
ref.current = value;
}, [value]);

return ref.current;
};

export default usePrevious;
262 changes: 128 additions & 134 deletions lib/RepeatableField/RepeatableField.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
Expand All @@ -8,147 +8,141 @@ import Button from '../Button';
import Headline from '../Headline';
import EmptyMessage from '../EmptyMessage';
import IconButton from '../IconButton';
import { RepeatableFieldContent } from "./RepeatableFieldContent";
import css from './RepeatableField.css';
import { useFocusedIndex } from "./hooks/useFocusedIndex";

class RepeatableField extends Component {
static propTypes = {
addLabel: PropTypes.node,
canAdd: PropTypes.bool,
canRemove: PropTypes.bool,
className: PropTypes.string,
emptyMessage: PropTypes.node,
fields: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object
]).isRequired,
getFieldUniqueKey: PropTypes.func,
hasMargin: PropTypes.bool,
headLabels: PropTypes.node,
id: PropTypes.string,
legend: PropTypes.oneOfType([
PropTypes.node,
PropTypes.string
]),
onAdd: PropTypes.func.isRequired,
onRemove: PropTypes.func,
renderField: PropTypes.func.isRequired,
};

static defaultProps = {
canAdd: true,
canRemove: true,
hasMargin: true,
getFieldUniqueKey: (_field, index) => index,
};
const RepeatableField = ({
canAdd = true,
canRemove = true,
hasMargin = true,
getFieldUniqueKey = (_field, index) => index,
addLabel,
className,
fields,
headLabels,
id,
legend,
emptyMessage,
onAdd,
onRemove,
renderField,
}) => {
const showDeleteBtn = typeof onRemove === 'function';
const fieldsLength = fields.length;
const focusedIndex = useFocusedIndex(fieldsLength);

render() {
const {
addLabel,
canAdd,
canRemove,
className,
fields,
getFieldUniqueKey,
hasMargin,
headLabels,
id,
legend,
emptyMessage,
onAdd,
onRemove,
renderField,
} = this.props;
return (
<fieldset
id={id}
data-test-repeatable-field
className={classnames(css.repeatableField, { [css.hasMargin]: hasMargin }, className)}
>
{legend && (
<Headline
data-test-repeatable-field-legend
tag="legend"
>
{legend}
</Headline>
)}

const showDeleteBtn = typeof onRemove === 'function';
{fieldsLength === 0 && emptyMessage && (
<EmptyMessage className={css.emptyMessage} data-test-repeatable-field-empty-message>
{emptyMessage}
</EmptyMessage>
)}

return (
<fieldset
id={id}
data-test-repeatable-field
className={classnames(css.repeatableField, { [css.hasMargin]: hasMargin }, className)}
>
{legend && (
<Headline
data-test-repeatable-field-legend
tag="legend"
>
{legend}
</Headline>
)}

{fields.length === 0 && emptyMessage && (
<EmptyMessage className={css.emptyMessage} data-test-repeatable-field-empty-message>
{emptyMessage}
</EmptyMessage>
)}

{fields.length > 0 && (
<ul className={css.repeatableFieldList} data-test-repeatable-field-list>
{headLabels && (
<li
className={css.repeatableFieldItemLabels}
data-test-repeatable-field-list-item-labels
{fieldsLength > 0 && (
<ul className={css.repeatableFieldList} data-test-repeatable-field-list>
{headLabels && (
<li
className={css.repeatableFieldItemLabels}
data-test-repeatable-field-list-item-labels
>
<div
className={showDeleteBtn
? css.repeatableFieldItemLabelsContentWithDelete
: css.repeatableFieldItemLabelsContent
}
>
<div
className={showDeleteBtn
? css.repeatableFieldItemLabelsContentWithDelete
: css.repeatableFieldItemLabelsContent
}
>
{headLabels}
</div>
</li>
)}
{headLabels}
</div>
</li>
)}

{fields.map((field, index) => (
<li
className={css.repeatableFieldItem}
key={getFieldUniqueKey(field, index, fields)}
data-test-repeatable-field-list-item
>
<div className={css.repeatableFieldItemContent}>
{renderField(field, index, fields)}
</div>
{
showDeleteBtn && (
<div
className={
`${css.repeatableFieldRemoveItem} ${headLabels ? css.repeatableFieldRemoveUnlabeledItem : ''}`
}
>
<FormattedMessage id="stripes-components.deleteThisItem">
{ ([label]) => (
<IconButton
data-test-repeatable-field-remove-item-button
icon="trash"
onClick={() => onRemove(index)}
size="medium"
disabled={!canRemove}
aria-label={label}
/>
)}
</FormattedMessage>
</div>
)
}
</li>
))}
</ul>
)}
{addLabel && (
<Button
data-test-repeatable-field-add-item-button
onClick={onAdd}
type="button"
disabled={!canAdd}
id={id && `${id}-add-button`}
>
{addLabel}
</Button>
)}
</fieldset>
);
}
{fields.map((field, index) => (
<li
className={css.repeatableFieldItem}
key={getFieldUniqueKey(field, index, fields)}
data-test-repeatable-field-list-item
>
<RepeatableFieldContent isFocused={focusedIndex === index} >
{renderField(field, index, fields)}
</RepeatableFieldContent>
{
showDeleteBtn && (
<div
className={
`${css.repeatableFieldRemoveItem} ${headLabels ? css.repeatableFieldRemoveUnlabeledItem : ''}`
}
>
<FormattedMessage id="stripes-components.deleteThisItem">
{ ([label]) => (
<IconButton
data-test-repeatable-field-remove-item-button
icon="trash"
onClick={() => onRemove(index)}
size="medium"
disabled={!canRemove}
aria-label={label}
/>
)}
</FormattedMessage>
</div>
)
}
</li>
))}
</ul>
)}
{addLabel && (
<Button
data-test-repeatable-field-add-item-button
onClick={onAdd}
type="button"
disabled={!canAdd}
id={id && `${id}-add-button`}
>
{addLabel}
</Button>
)}
</fieldset>
)
}

RepeatableField.propTypes = {
addLabel: PropTypes.node,
canAdd: PropTypes.bool,
canRemove: PropTypes.bool,
className: PropTypes.string,
emptyMessage: PropTypes.node,
fields: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object
]).isRequired,
getFieldUniqueKey: PropTypes.func,
hasMargin: PropTypes.bool,
headLabels: PropTypes.node,
id: PropTypes.string,
legend: PropTypes.oneOfType([
PropTypes.node,
PropTypes.string
]),
onAdd: PropTypes.func.isRequired,
onRemove: PropTypes.func,
renderField: PropTypes.func.isRequired,
}

export default formFieldArray(
Expand Down
32 changes: 32 additions & 0 deletions lib/RepeatableField/RepeatableFieldContent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { useCallback } from "react";
import PropTypes from "prop-types";

import { getFirstFocusable } from "../../util/getFocusableElements";

import css from "./RepeatableField.css";

export const RepeatableFieldContent = ({ children, isFocused }) => {
const callbackRef = useCallback((node) => {
if (node) {
const elem = getFirstFocusable(node, true, true);

if (isFocused) {
elem?.focus();
}
}
}, [isFocused])

return (
<div className={css.repeatableFieldItemContent} ref={callbackRef}>
{children}
</div>
);
}

RepeatableFieldContent.propTypes = {
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.func,
]).isRequired,
isFocused: PropTypes.bool.isRequired,
}
18 changes: 18 additions & 0 deletions lib/RepeatableField/hooks/useFocusedIndex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import usePrevious from "../../../hooks/usePrevious";
import { useEffect, useState } from "react";

export const useFocusedIndex = (fieldsLength) => {
const [focusIndex, setFocusIndex] = useState(null);

const prevFieldsLength = usePrevious(fieldsLength);

useEffect(() => {
if (fieldsLength > prevFieldsLength) { // added
setFocusIndex(fieldsLength - 1);
} else { // removed
setFocusIndex(null);
}
}, [fieldsLength])

return focusIndex;
};
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
"focusBeyond"
]
},
"resolutions": {
"polished": "4.2.2"
},
"devDependencies": {
"@babel/core": "^7.8.0",
"@babel/eslint-parser": "^7.17.0",
Expand Down

0 comments on commit 7532ec4

Please sign in to comment.