Skip to content

Commit

Permalink
STCOM-1330 FilterAccordionHeader - focus accordion header after clear…
Browse files Browse the repository at this point in the history
… button is pressed. (#2335)

* filterAccordionHeader - focus accordion header after clear button is pressed

* log changes

* add block of tests for FilterAccordionHeader as part of Accordion stuite.
  • Loading branch information
JohnC-80 authored Aug 19, 2024
1 parent 7bf007b commit 08369c7
Show file tree
Hide file tree
Showing 4 changed files with 193 additions and 104 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
* Conform `<Selection>`'s internal state when dataOptions prop changes after initial render. Refs STCOM-1313.
* Add the `showSortIndicator` property to MLC to display a sort indicator next to the sortable column names. Refs STCOM-1328.
* Expose `aria-label` for SearchField Index `<Select>`. Refs STCOM-1329.
* `<FilterAccordionHeader>` - move focus to accordion header after clear button is pressed. Refs STCOM-1330.

## [12.1.0](https://github.com/folio-org/stripes-components/tree/v12.1.0) (2024-03-12)
[Full Changelog](https://github.com/folio-org/stripes-components/compare/v12.0.0...v12.1.0)
Expand Down
205 changes: 101 additions & 104 deletions lib/Accordion/headers/FilterAccordionHeader.js
Original file line number Diff line number Diff line change
@@ -1,123 +1,120 @@
import React from 'react';
import { injectIntl } from 'react-intl';
import { useCallback, useRef, useImperativeHandle } from 'react';
import { useIntl } from 'react-intl';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Icon from '../../Icon';
import Headline from '../../Headline';
import IconButton from '../../IconButton';
import css from '../Accordion.css';

class FilterAccordionHeader extends React.Component {
static propTypes = {
autoFocus: PropTypes.bool,
contentId: PropTypes.string,
disabled: PropTypes.bool,
displayClearButton: PropTypes.bool,
displayWhenClosed: PropTypes.element,
displayWhenOpen: PropTypes.element,
headerProps: PropTypes.object,
headingLevel: PropTypes.oneOf([1, 2, 3, 4, 5, 6]),
id: PropTypes.string,
intl: PropTypes.object,
label: PropTypes.oneOfType([PropTypes.element, PropTypes.string]).isRequired,
labelId: PropTypes.string,
name: PropTypes.string,
onClearFilter: PropTypes.func,
onToggle: PropTypes.func,
open: PropTypes.bool,
toggleRef: PropTypes.func,
};
const FilterAccordionHeader = ({
autoFocus,
contentId,
disabled,
displayWhenOpen,
displayWhenClosed,
displayClearButton = true,
headingLevel = 3,
headerProps,
id,
label,
labelId,
name,
onClearFilter,
onToggle,
open,
toggleRef: toggleRefProp,
}) => {
const { formatMessage } = useIntl();
const toggleRef = useRef(null);
useImperativeHandle(toggleRefProp, () => toggleRef.current);

static defaultProps = {
displayClearButton: true,
headingLevel: 3
};

handleHeaderClick = (e) => {
const handleHeaderClick = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
const { id, label } = this.props;
this.props.onToggle({ id, label });
}
onToggle({ id, label });
}, [id, label, onToggle]);

handleClearButtonClick = () => {
this.props.onClearFilter(this.props.name);
}
const handleClearButtonClick = useCallback(() => {
toggleRef.current?.focus();
onClearFilter(name);
}, [onClearFilter, name, toggleRef]);

handleKeyPress = (e) => {
const handleKeyPress = useCallback((e) => {
e.preventDefault();
if (e.charCode === 13) { // 13 = enter
const { id, label } = this.props;
this.props.onToggle({ id, label });
onToggle({ id, label });
}
}
}, [id, label, onToggle]);

render() {
const {
label,
open,
disabled,
displayWhenOpen,
displayWhenClosed,
displayClearButton,
onClearFilter,
contentId,
headingLevel,
intl: {
formatMessage
},
headerProps,
labelId,
} = this.props;
const clearButtonVisible = displayClearButton && typeof onClearFilter === 'function';
const labelPropsId = label?.props?.id;
const labelText = labelPropsId ? formatMessage({ id: labelPropsId }) : label || '-';
const clearButtonVisible = displayClearButton && typeof onClearFilter === 'function';
const labelPropsId = label?.props?.id;
const labelText = labelPropsId ? formatMessage({ id: labelPropsId }) : label || '-';

return (
<div className={css.headerWrapper}>
<Headline size="small" tag={`h${headingLevel}`} block={!clearButtonVisible}>
<button
type="button"
tabIndex="0"
onClick={this.handleHeaderClick}
onKeyPress={this.handleKeyPress}
aria-label={`${labelText} filter list`}
aria-controls={contentId}
aria-expanded={open}
disabled={disabled}
className={classNames(css.filterSetHeader, { [css.clearButtonVisible]: clearButtonVisible })}
autoFocus={this.props.autoFocus}
ref={this.props.toggleRef}
id={labelId}
{...headerProps}
>
{
disabled ? null :
<Icon
iconClassName={css.filterSetHeaderIcon}
size="small"
icon={`caret-${open ? 'up' : 'down'}`}
/>
}
<div className={css.labelArea}>
<div className={css.filterSetLabel}>{label}</div>
</div>
</button>
</Headline>
{ clearButtonVisible ?
<IconButton
data-test-clear-button
size="small"
iconSize="small"
icon="times-circle-solid"
aria-label={`Clear selected filters for "${label}"`}
onClick={this.handleClearButtonClick}
/> : null
}
{open ? displayWhenOpen : displayWhenClosed}
</div>
);
}
return (
<div className={css.headerWrapper}>
<Headline size="small" tag={`h${headingLevel}`} block={!clearButtonVisible}>
<button
type="button"
tabIndex="0"
onClick={handleHeaderClick}
onKeyPress={handleKeyPress}
aria-label={`${labelText} filter list`}
aria-controls={contentId}
aria-expanded={open}
disabled={disabled}
className={classNames(css.filterSetHeader, { [css.clearButtonVisible]: clearButtonVisible })}
autoFocus={autoFocus}
ref={toggleRef}
id={labelId}
{...headerProps}
>
{
disabled ? null :
<Icon
iconClassName={css.filterSetHeaderIcon}
size="small"
icon={`caret-${open ? 'up' : 'down'}`}
/>
}
<div className={css.labelArea}>
<div className={css.filterSetLabel}>{label}</div>
</div>
</button>
</Headline>
{ clearButtonVisible ?
<IconButton
data-test-clear-button
size="small"
iconSize="small"
icon="times-circle-solid"
aria-label={`Clear selected filters for "${label}"`}
onClick={handleClearButtonClick}
/> : null
}
{open ? displayWhenOpen : displayWhenClosed}
</div>
);
}

export default injectIntl(FilterAccordionHeader);
FilterAccordionHeader.propTypes = {
autoFocus: PropTypes.bool,
contentId: PropTypes.string,
disabled: PropTypes.bool,
displayClearButton: PropTypes.bool,
displayWhenClosed: PropTypes.element,
displayWhenOpen: PropTypes.element,
headerProps: PropTypes.object,
headingLevel: PropTypes.oneOf([1, 2, 3, 4, 5, 6]),
id: PropTypes.string,
intl: PropTypes.object,
label: PropTypes.oneOfType([PropTypes.element, PropTypes.string]).isRequired,
labelId: PropTypes.string,
name: PropTypes.string,
onClearFilter: PropTypes.func,
onToggle: PropTypes.func,
open: PropTypes.bool,
toggleRef: PropTypes.func,
};

export default FilterAccordionHeader;
89 changes: 89 additions & 0 deletions lib/Accordion/tests/Accordion-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import UsageWithAccordionSet from '../stories/BasicUsage';

import Accordion from '../Accordion';
import AccordionSet from '../AccordionSet';
import { FilterAccordionHeader } from '../headers';

const Focused = Button.extend('Accordion header')
.selector(':focus')
Expand Down Expand Up @@ -287,6 +288,94 @@ describe('Accordion - as part of an AccordionSet', () => {
});
});

describe('Accordion - with FilterAccordionHeader', () => {
const first = Interactor({ index: 0 });
const second = Interactor({ index: 1 });
const last = Interactor({ index: 3 });

const onRegisterAccordionSpy = sinon.spy();

beforeEach(async () => {
await mountWithContext(
<AccordionSet
initialStatus={{
test1: false,
test2: true,
test3: true,
test4: true,
}}
onRegisterAccordion={onRegisterAccordionSpy}
>
<Accordion label="test1" id="test1" header={FilterAccordionHeader}>
<input aria-label="test1" type="text" id="testControl1" />
</Accordion>
<Accordion label="test2" id="test2" header={FilterAccordionHeader}>
<input aria-label="test2" />
</Accordion>
<Accordion label="test3" id="test3" header={FilterAccordionHeader}>
<input aria-label="test3" />
</Accordion>
<Accordion label="test4" id="test4" header={FilterAccordionHeader}>
<input aria-label="test4" />
</Accordion>
</AccordionSet>
);
});

it('has no axe errors', () => runAxeTest);

it('has a button', () => Button('test1').exists());

it('should call onRegisterAccordion callback', () => {
return expect(onRegisterAccordionSpy.calledWith('test1')).to.be.true;
});

describe('contents ready for interaction following open', () => {
beforeEach(async () => {
await first.clickHeader();
await Bigtest.TextField({ id: 'testControl1' }).fillIn('test');
});

it('Child element was filled out successfully', () => Bigtest.TextField({id: 'testControl1'}).has({ value: 'test' }));
});

describe('keyboard navigation: next accordion', () => {
beforeEach(async () => {
await first.focus();
await Focused('test1').pressKey('ArrowDown');
});

it('second accordion is in focus', async () => second.is({ focused: true }));
});

describe('keyboard navigation: previous accordion', () => {
beforeEach(async () => {
await second.focus();
await Focused('test2').pressKey('ArrowUp');
});

it('first accordion is in focus', async () => first.is({ focused: true }));
});

describe('keyboard navigation: last accordion', () => {
beforeEach(async () => {
await first.focus();
await Focused('test1').pressKey('End');
});

it('Last accordion is in focus', async () => last.is({ focused: true }));
});

describe('keyboard navigation: first accordion', () => {
beforeEach(async () => {
await last.focus();
await Focused('test4').pressKey('Home');
});

it('First accordion is in focus', async () => first.is({ focused: true }));
});
});

describe('unmounting Accordion - as part of an AccordionSet', () => {
const onUnregisterAccordionSpy = sinon.spy();

Expand Down
2 changes: 2 additions & 0 deletions lib/FilterGroups/tests/FilterGroups-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ describe('Filter Group', () => {
]));

it('has Clear button', () => FilterAccordion('Item Types').has({ clearButton: false }));

it('toggle is in focus', () => Button('Item Types').is({ focused: true }));
});
});
});
Expand Down

0 comments on commit 08369c7

Please sign in to comment.