Skip to content

Commit 973d844

Browse files
author
Kubit
committed
Add a prop to read the numbers of results
Add a prop to read the numbers of results on InputSearch component
1 parent 120adcf commit 973d844

21 files changed

+332
-29
lines changed
Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,38 @@
11
import { render } from '@testing-library/react';
2-
import React, { MutableRefObject, createRef } from 'react';
2+
import React, { MutableRefObject } from 'react';
33

4-
import { getCharactersLength } from '../utils';
4+
import { buildScreenReaderText, getCharactersLength } from '../utils';
55

6+
const input = document.createElement('input');
7+
const maxLength = 10;
8+
const inputRef = { current: input };
69
describe('Counter Utils', () => {
7-
const ref = { current: null };
810
it('Should have value', async () => {
911
const value = 'test';
10-
expect(getCharactersLength(value, ref)).toBe(value.length);
12+
expect(getCharactersLength(value, inputRef)).toBe(value.length);
1113
});
1214

1315
it('Should have inputRef', async () => {
1416
const refValue = 'test';
15-
const ref = createRef<HTMLInputElement>();
16-
render(<input ref={ref} value={refValue} onChange={jest.fn()} />);
17-
expect(getCharactersLength(undefined, ref as MutableRefObject<HTMLInputElement>)).toBe(
17+
render(<input ref={inputRef} value={refValue} onChange={jest.fn()} />);
18+
expect(getCharactersLength(undefined, inputRef as MutableRefObject<HTMLInputElement>)).toBe(
1819
refValue.length
1920
);
2021
});
2122

2223
it('Should return 0', async () => {
23-
expect(getCharactersLength(undefined, ref)).toBe(0);
24+
expect(getCharactersLength(undefined, inputRef)).toBe(0);
25+
});
26+
it('returns undefined when screenReaderText is undefined', () => {
27+
const input = document.createElement('input');
28+
const inputRef = { current: input };
29+
expect(buildScreenReaderText('test', inputRef, maxLength)).toBeUndefined();
30+
});
31+
32+
it('replaces current characters and max length keys correctly', () => {
33+
const screenReaderText =
34+
'You have typed {{currentCharacters}} out of {{maxLength}} characters.';
35+
const expectedText = 'You have typed 4 out of 10 characters.';
36+
expect(buildScreenReaderText('test', inputRef, maxLength, screenReaderText)).toBe(expectedText);
2437
});
2538
});

src/components/inputCounter/inputCounter.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ErrorBoundary, FallbackComponent } from '@/provider/errorBoundary';
77
import { INTERNAL_ERROR_EXECUTION, InputTypeType } from '../input/types';
88
import { InputCounterStandAlone } from './inputCounterStandAlone';
99
import { IInputCounter, IInputCounterStandAlone, InputCounterStylesProps } from './types';
10+
import { buildScreenReaderText } from './utils';
1011

1112
const InputCounterComponent = React.forwardRef(
1213
<V extends string | unknown>(
@@ -41,13 +42,14 @@ const InputCounterComponent = React.forwardRef(
4142
}: IInputCounter<V>,
4243
ref: React.ForwardedRef<HTMLInputElement | undefined>
4344
): JSX.Element => {
45+
const uniqueId = useId('inputCounter');
46+
const inputId = props.id ?? uniqueId;
47+
4448
const styles = useStyles<InputCounterStylesProps, V>(
4549
STYLES_NAME.INPUT_COUNTER,
4650
props.variant,
4751
ctv
4852
);
49-
const uniqueId = useId('inputCounter');
50-
const inputId = props.id ?? uniqueId;
5153

5254
const {
5355
value,
@@ -87,6 +89,18 @@ const InputCounterComponent = React.forwardRef(
8789
onPaste,
8890
});
8991

92+
const [showMessage, setShowMessage] = React.useState(false);
93+
94+
const handleChange = e => {
95+
handleChangeInternal?.(e);
96+
setShowMessage(false);
97+
};
98+
99+
const handleBlur = e => {
100+
handleBlurInternal?.(e);
101+
setShowMessage(true);
102+
};
103+
90104
return (
91105
<InputCounterStandAlone
92106
{...props}
@@ -97,13 +111,20 @@ const InputCounterComponent = React.forwardRef(
97111
maxLength={maxLength}
98112
min={min}
99113
minLength={minLength}
114+
screenReaderCurrentCharacters={buildScreenReaderText(
115+
value,
116+
ref as React.MutableRefObject<HTMLInputElement>,
117+
maxLength,
118+
props.screenReaderCurrentCharacters
119+
)}
120+
showMessage={showMessage}
100121
state={state}
101122
styles={styles}
102123
truncate={truncate}
103124
type={type}
104125
value={value}
105-
onBlur={handleBlurInternal}
106-
onChange={handleChangeInternal}
126+
onBlur={handleBlur}
127+
onChange={handleChange}
107128
onFocus={handleFocusInternal}
108129
onKeyDown={handleKeyDownInternal}
109130
onPaste={handlePasteInternal}

src/components/inputCounter/inputCounterStandAlone.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@ import * as React from 'react';
33
import { TextCount } from '@/components/textCount/textCount';
44

55
import { InputControlled as Input } from '../input';
6+
import { ScreenReaderOnly } from '../screenReaderOnly';
67
import { IInputCounterStandAlone } from './types';
78
import { getCharactersLength } from './utils';
89

910
export const InputCounterStandAloneComponent = <V extends string | unknown>(
1011
props: IInputCounterStandAlone<V>,
1112
ref: React.ForwardedRef<HTMLInputElement | undefined | null>
1213
): JSX.Element => {
14+
const screenReaderCurrentCharactersId = 'screenReaderCurrentCharactersId';
1315
const renderTextCounter = (): React.ReactNode => {
1416
if (!props.styles?.[props.state]?.textCountVariant) {
1517
return null;
1618
}
19+
1720
return (
1821
<TextCount
1922
currentCharacters={getCharactersLength(
@@ -36,14 +39,20 @@ export const InputCounterStandAloneComponent = <V extends string | unknown>(
3639
};
3740

3841
return (
39-
<Input
40-
{...props}
41-
ref={ref}
42-
id={props.id}
43-
overrideStyles={props.styles}
44-
textCounter={renderTextCounter()}
45-
variant={props.inputVariant ?? props.styles?.[props.state]?.inputVariant}
46-
/>
42+
<>
43+
<Input
44+
{...props}
45+
ref={ref}
46+
aria-describedby={props.showMessage ? screenReaderCurrentCharactersId : undefined}
47+
id={props.id}
48+
overrideStyles={props.styles}
49+
textCounter={renderTextCounter()}
50+
variant={props.inputVariant ?? props.styles?.[props.state]?.inputVariant}
51+
/>
52+
<ScreenReaderOnly id={screenReaderCurrentCharactersId}>
53+
{props.screenReaderCurrentCharacters}
54+
</ScreenReaderOnly>
55+
</>
4756
);
4857
};
4958

src/components/inputCounter/stories/argtypes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ export const argtypes = (variants: IThemeObjectVariants, themeSelected: string):
3737
category: CATEGORY_CONTROL.CONTENT,
3838
},
3939
},
40+
screenReaderCurrentCharacters: {
41+
type: { name: 'string' },
42+
description:
43+
'The counter information has to be announce to screen reader users when the input receives the focus',
44+
control: { type: 'text' },
45+
table: {
46+
type: {
47+
summary: 'string',
48+
},
49+
category: CATEGORY_CONTROL.CONTENT,
50+
},
51+
},
4052
maxLength: {
4153
type: { name: 'string', required: true },
4254
description:

src/components/inputCounter/stories/inputCounter.stories.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ const commonArgs: IInputCounter = {
3737
placeholder: 'Counter',
3838
secondaryLabel: labelSecondary(themeSelected),
3939
additionalInfo: additionalInfoAction(themeSelected),
40-
screenReaderTextCount: 'screenReaderTextCount',
40+
screenReaderTextCount: 'screen reader text min / max reached',
41+
screenReaderCurrentCharacters: '{{currentCharacters}} of {{maxLength}} characters filled',
4142
};
4243

4344
export const Inputcounter: Story = {

src/components/inputCounter/types/inputCounter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export type InputCounterTextCountType = Omit<ITextCountControlled, 'variant' | '
88
variant?: string;
99
};
1010

11+
export const INPUT_COUNTER_BUILD_SCREEN_READER_CURRENT_CHARACTERS_KEY = '{{currentCharacters}}';
12+
export const INPUT_COUNTER_BUILD_SCREEN_READER_MAX_LENGTH_KEY = '{{maxLength}}';
13+
1114
type propsToOmitInputBasic =
1215
| 'styles'
1316
| 'inputId'
@@ -24,11 +27,13 @@ export interface IInputCounterStandAlone<V = undefined extends string ? unknown
2427
variant: V;
2528
inputVariant?: string;
2629
screenReaderTextCount: string;
30+
screenReaderCurrentCharacters?: string;
2731
maxLength: number;
32+
showMessage?: boolean;
2833
textCount?: InputCounterTextCountType;
2934
}
3035

31-
type propsToOmit = 'styles' | 'state';
36+
type propsToOmit = 'styles' | 'state' | 'showMessage';
3237

3338
export interface IInputCounter<V = undefined extends string ? unknown : string>
3439
extends Omit<IInputCounterStandAlone<V>, propsToOmit>,

src/components/inputCounter/utils/counter.utils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { ForwardedRef, MutableRefObject } from 'react';
22

3+
import {
4+
INPUT_COUNTER_BUILD_SCREEN_READER_CURRENT_CHARACTERS_KEY,
5+
INPUT_COUNTER_BUILD_SCREEN_READER_MAX_LENGTH_KEY,
6+
} from '../types/inputCounter';
7+
38
export const getCharactersLength = (
49
value: string | number | undefined,
510
ref: ForwardedRef<HTMLInputElement | undefined>
@@ -11,3 +16,28 @@ export const getCharactersLength = (
1116
(ref as MutableRefObject<HTMLInputElement | null | undefined>)?.current?.value?.length || 0
1217
);
1318
};
19+
20+
export const buildScreenReaderText = (
21+
value: string | number | undefined,
22+
ref: MutableRefObject<HTMLInputElement>,
23+
maxLength: number,
24+
screenReaderText?: string
25+
): string | undefined => {
26+
if (!screenReaderText) {
27+
return screenReaderText;
28+
}
29+
30+
const currentCharacters = getCharactersLength(
31+
value,
32+
ref as React.MutableRefObject<HTMLInputElement>
33+
);
34+
const currentCharactersRegExp = new RegExp(
35+
INPUT_COUNTER_BUILD_SCREEN_READER_CURRENT_CHARACTERS_KEY,
36+
'g'
37+
);
38+
const maxlengthRegExp = new RegExp(INPUT_COUNTER_BUILD_SCREEN_READER_MAX_LENGTH_KEY, 'g');
39+
40+
return screenReaderText
41+
.replace(currentCharactersRegExp, String(currentCharacters))
42+
.replace(maxlengthRegExp, String(maxLength));
43+
};

src/components/inputSearch/__tests__/helpers.test.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { filterOptions, getAriaControls, getLength, hasMatchWithOptions } from '../helpers';
1+
import {
2+
buildOptionsScreenReaderText,
3+
filterOptions,
4+
getAriaControls,
5+
getLength,
6+
hasMatchWithOptions,
7+
shouldOpenPopover,
8+
} from '../helpers';
29

310
const optionsToFilter = [
411
{
@@ -65,3 +72,55 @@ describe('Input Search Helpers', () => {
6572
expect(hasMatchWithOptions(wrongValue, options)).toBeFalsy();
6673
});
6774
});
75+
76+
describe('shouldOpenPopover', () => {
77+
it('returns false when open is false', () => {
78+
expect(shouldOpenPopover({ open: false, options: [] })).toBe(false);
79+
});
80+
81+
it('returns true when useActionBottomSheet is true', () => {
82+
expect(shouldOpenPopover({ open: true, useActionBottomSheet: true, options: [] })).toBe(true);
83+
});
84+
85+
it('returns true when options array length is greater than 0', () => {
86+
expect(shouldOpenPopover({ open: true, options: [{ options: ['Option 1'] }] })).toBe(true);
87+
});
88+
89+
it('returns true when loadingList is true', () => {
90+
expect(shouldOpenPopover({ open: true, options: [], loadingList: true })).toBe(true);
91+
});
92+
93+
it('returns true when noResultText has content', () => {
94+
expect(
95+
shouldOpenPopover({ open: true, options: [], noResultText: { content: 'No results found' } })
96+
).toBe(true);
97+
});
98+
99+
it('returns true when hasHighlightedOption is true', () => {
100+
expect(shouldOpenPopover({ open: true, options: [], hasHighlightedOption: true })).toBe(true);
101+
});
102+
103+
it('returns false when none of the conditions are met', () => {
104+
expect(shouldOpenPopover({ open: true, options: [] })).toBe(false);
105+
});
106+
});
107+
108+
describe('screenReader', () => {
109+
it('buildOptionsScreenReaderText - should return falsy if optionsScreenReaderText does not exist', () => {
110+
const result = buildOptionsScreenReaderText({
111+
numOptions: 1,
112+
numOptionsFiltered: 1,
113+
optionsScreenReaderText: undefined,
114+
});
115+
expect(result).toBeFalsy();
116+
});
117+
118+
it('buildOptionsScreenReaderText - should return optionsScreenReaderText with replaced values', () => {
119+
const result = buildOptionsScreenReaderText({
120+
numOptions: 1,
121+
numOptionsFiltered: 1,
122+
optionsScreenReaderText: '{{numOptionsFiltered}} of {{numOptions}}',
123+
});
124+
expect(result).toBe('1 of 1');
125+
});
126+
});

src/components/inputSearch/components/optionsList.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import * as React from 'react';
22
import { ForwardedRef, forwardRef, useCallback, useMemo } from 'react';
33

44
import { ListOptions } from '@/components/listOptions/listOptions';
5+
import { ROLES } from '@/types';
56

7+
import { buildOptionsScreenReaderText } from '../helpers';
68
import { MultipleRef } from '../hooks/useInputSearch';
9+
import { NumMatchesScreenReader } from '../inputSearch.styled';
710
import { IOptionsListSearchList } from '../types';
811
import { LoadingIcon } from './loadingIcon';
912

@@ -69,6 +72,15 @@ export const OptionsListComponent = (
6972
}}
7073
/>
7174
)}
75+
<NumMatchesScreenReader role={ROLES.STATUS}>
76+
{buildOptionsScreenReaderText({
77+
numOptions: props.hightlightedOption
78+
? props.initialOptionsLength + 1
79+
: props.initialOptionsLength,
80+
numOptionsFiltered: _options.length,
81+
optionsScreenReaderText: props.optionsScreenReaderText,
82+
})}
83+
</NumMatchesScreenReader>
7284
<LoadingIcon
7385
expanded={_options.length === 0}
7486
loader={props.loader}

src/components/inputSearch/components/popoverSearchList.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { ForwardedRef, forwardRef, useMemo } from 'react';
33

44
import { Input } from '@/components/input';
55
// components
6-
import { PopoverControlled as Popover } from '@/components/popover';
6+
import { PopoverControlled as Popover, PopoverComponentType } from '@/components/popover';
77
import { ScreenReaderOnly } from '@/components/screenReaderOnly';
88
import { Text } from '@/components/text';
99
import { useId } from '@/hooks';
10-
import { AriaLiveOptionType } from '@/types';
10+
import { AriaLiveOptionType, ROLES } from '@/types';
1111

1212
import { ActionBottomSheetControlledStructure as ActionBottomSheet } from '../../actionBottomSheet/actionBottomSheetControlled';
1313
// helpers
@@ -73,12 +73,14 @@ export const PopoverSearchListComponent = (
7373
dataTestId={`${props.dataTestId}OptionsList${index}`}
7474
hightlightedOption={showTextWritten || showHighlightedOption}
7575
index={index}
76+
initialOptionsLength={props.initialOptionsLength}
7677
loader={props.loader}
7778
loading={props.loading}
7879
loadingText={props.loadingText}
7980
optionCheckedIcon={props.optionCheckedIcon}
8081
optionVariant={section?.optionVariant}
8182
options={section?.options || []}
83+
optionsScreenReaderText={props.optionsScreenReaderText}
8284
searchText={labelInResultTextWrittenByUser}
8385
stylesListOption={props.styles.listOptions}
8486
stylesState={props.styles?.[props.state]}
@@ -156,12 +158,15 @@ export const PopoverSearchListComponent = (
156158
return (
157159
<Popover
158160
hasBackDrop
161+
aria-modal={useActionBottomSheet ? true : undefined}
159162
blockBack={props.blockBackPopover}
163+
component={useActionBottomSheet ? PopoverComponentType.DIV : undefined}
160164
dataTestId={`${props.dataTestId}Popover`}
161165
focusFirstDescendantAutomatically={false}
162166
focusLastElementFocusedAfterClose={false}
163167
open={props.open}
164168
preventCloseOnClickElements={props.preventCloseOnClickElements}
169+
role={useActionBottomSheet ? ROLES.DIALOG : undefined}
165170
trapFocusInsideModal={useActionBottomSheet}
166171
variant={props.styles?.[props.state]?.popoverVariant?.[props.device]}
167172
onCloseInternally={() => {

0 commit comments

Comments
 (0)