Skip to content

Commit

Permalink
STCOM-1372 Use mutationobserver for focus management in repeatable fi…
Browse files Browse the repository at this point in the history
…eld (#2376)

* use mutationobserver for focusmanagement in repeatable field

* cleanup RFContent, mild refactor, check for RF-items addition, deletion, specifically.

* use state to check if the user has interacted with the repeatableField

* add comments for conditionals

* Update CHANGELOG.md
  • Loading branch information
JohnC-80 authored Oct 28, 2024
1 parent 4b06eed commit d7e23fb
Show file tree
Hide file tree
Showing 3 changed files with 59 additions and 31 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Change `Repeatable field` focus behaviour. Refs STCOM-1341.
* Fix `<Selection>` bug with option list closing when scrollbar is used. Refs STCOM-1371.
* `<Selection>` - fix bug handling empty string options/values. Refs STCOM-1373.
* `<RepeatableField>` - switch to MutationObserver to resolve focus-management issues. Refs STCOM-1372.

## [12.2.0](https://github.com/folio-org/stripes-components/tree/v12.2.0) (2024-10-11)
[Full Changelog](https://github.com/folio-org/stripes-components/compare/v12.1.0...v12.2.0)
Expand Down
59 changes: 50 additions & 9 deletions lib/RepeatableField/RepeatableField.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
Expand All @@ -8,11 +8,9 @@ import Button from '../Button';
import Headline from '../Headline';
import EmptyMessage from '../EmptyMessage';
import IconButton from '../IconButton';
import { RepeatableFieldContent } from "./RepeatableFieldContent";
import { RepeatableFieldContent } from './RepeatableFieldContent';
import css from './RepeatableField.css';
import { useFocusedIndex } from "./hooks/useFocusedIndex";
import { useIsElementFocused } from "./hooks/useIsElementFocused";

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

const RepeatableField = ({
canAdd = true,
Expand All @@ -33,16 +31,60 @@ const RepeatableField = ({
const rootRef = useRef(null);
const showDeleteBtn = typeof onRemove === 'function';
const fieldsLength = fields.length;
const isSomeChildElementFocused = useIsElementFocused(rootRef);
const focusedIndex = useFocusedIndex(fieldsLength);
const hasToBeFocused = (index) => isSomeChildElementFocused && focusedIndex === index;
const [hasBeenFocused, setHasBeenFocused] = useState(false);

// use mutation observers to handle focus-management since we only have internal state.
useEffect(() => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type !== 'childList') return;

const addedNode = mutation.addedNodes?.[0];
const removedNode = mutation.removedNodes?.[0];
let rowElem;
// Handle added node
// we check if the user has interacted with the component before handling this, otherwise focus could be
// unwantedly moved when a form is initialized.
if (hasBeenFocused) {
if (addedNode &&
addedNode.nodeType === 1 && // looking for nodeType: element only... not attribute (2) or text (3)
addedNode.matches(`.${css.repeatableFieldItem}`)) { // only apply to repeatable field item addition removal
rowElem = getFirstFocusable(addedNode);
rowElem?.focus();
}
}

// Handle removed node
if (removedNode &&
mutation.previousSibling &&
mutation.previousSibling.matches(`.${css.repeatableFieldItem}`)) {
rowElem = getFirstFocusable(mutation.previousSibling);
rowElem?.focus();
}
});
});

if (rootRef.current) {
// observe for item additions/removals from list.
observer.observe(rootRef.current, {
childList: true,
subtree: true,
});
}

return () => {
observer.disconnect();
};
}, [hasBeenFocused])

return (
<fieldset
ref={rootRef}
id={id}
data-test-repeatable-field
className={classnames(css.repeatableField, { [css.hasMargin]: hasMargin }, className)}
tabIndex="-1"
onFocus={() => { setHasBeenFocused(true) }}
>
{legend && (
<Headline
Expand Down Expand Up @@ -85,7 +127,6 @@ const RepeatableField = ({
>
<RepeatableFieldContent
rootRef={rootRef}
isFocused={hasToBeFocused(index)}
>
{renderField(field, index, fields)}
</RepeatableFieldContent>
Expand Down
30 changes: 8 additions & 22 deletions lib/RepeatableField/RepeatableFieldContent.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,17 @@
import React, { useCallback } from "react";
import PropTypes from "prop-types";
import React from 'react';
import PropTypes from 'prop-types';

import { getFirstFocusable } from "../../util/getFocusableElements";
import css from './RepeatableField.css';

import css from "./RepeatableField.css";

export const RepeatableFieldContent = ({ children, isFocused }) => {

const callbackRef = useCallback((node) => {
if (node && isFocused) {
const elem = getFirstFocusable(node, true, true);

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

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

RepeatableFieldContent.propTypes = {
children: PropTypes.oneOfType([
PropTypes.node,
PropTypes.func,
]).isRequired,
isFocused: PropTypes.bool.isRequired,
}

0 comments on commit d7e23fb

Please sign in to comment.