Skip to content

Commit

Permalink
fix: Makes autosuggest input use aria-expanded every time the dropdow…
Browse files Browse the repository at this point in the history
…n is shown
  • Loading branch information
pan-kot committed Dec 5, 2024
1 parent a5be1f7 commit 4284824
Show file tree
Hide file tree
Showing 5 changed files with 265 additions and 111 deletions.
49 changes: 27 additions & 22 deletions pages/autosuggest/simple.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import React, { useRef, useState } from 'react';

import Autosuggest, { AutosuggestProps } from '~components/autosuggest';
import { Autosuggest, AutosuggestProps, Box, SpaceBetween } from '~components';

const empty = <span>Nothing found</span>;
const options = [
Expand All @@ -18,27 +18,32 @@ export default function AutosuggestPage() {
const [hasOptions, setHasOptions] = useState(true);
const ref = useRef<AutosuggestProps.Ref>(null);
return (
<div style={{ padding: 10 }}>
<Box margin="m">
<h1>Simple autosuggest</h1>
<Autosuggest
ref={ref}
value={value}
readOnly={readOnly}
options={hasOptions ? options : []}
onChange={event => setValue(event.detail.value)}
enteredTextLabel={enteredTextLabel}
ariaLabel={'simple autosuggest'}
selectedAriaLabel="Selected"
empty={empty}
filteringResultsText={matchesCount => `${matchesCount} items`}
/>
<button id="remove-options" onClick={() => setHasOptions(false)}>
set empty options
</button>
<button id="set-read-only" onClick={() => setReadOnly(true)}>
set read only
</button>
<button onClick={() => ref.current?.focus()}>focus</button>
</div>
<SpaceBetween size="l">
<Autosuggest
ref={ref}
value={value}
readOnly={readOnly}
options={hasOptions ? options : []}
onChange={event => setValue(event.detail.value)}
enteredTextLabel={enteredTextLabel}
ariaLabel={'simple autosuggest'}
selectedAriaLabel="Selected"
empty={empty}
filteringResultsText={matchesCount => `${matchesCount} items`}
/>

<Box>
<button id="remove-options" onClick={() => setHasOptions(false)}>
set empty options
</button>
<button id="set-read-only" onClick={() => setReadOnly(true)}>
set read only
</button>
<button onClick={() => ref.current?.focus()}>focus</button>
</Box>
</SpaceBetween>
</Box>
);
}
15 changes: 13 additions & 2 deletions src/__a11y__/to-validate-a11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ async function toValidateA11y(this: jest.MatcherUtils, element: HTMLElement) {

const htmlValidateResult = htmlValidator.validateString(element.outerHTML);

const pass = axeResult.violations.length === 0 && axeResult.incomplete.length === 0 && htmlValidateResult.valid;
const axeViolations = getAxeViolations(axeResult);
const pass = axeViolations.length === 0 && htmlValidateResult.valid;
if (pass) {
return { pass, message: () => 'OK' };
}
Expand All @@ -72,7 +73,6 @@ async function toValidateA11y(this: jest.MatcherUtils, element: HTMLElement) {
const htmlViolations = (htmlValidateResult.results[0]?.messages || []).map(
message => `${message.message} [${message.ruleId}]`
);
const axeViolations = axeResult.violations.concat(axeResult.incomplete);
// TODO: remove polyfill with es2019 support
const flattenAxeViolations = flatMap(axeViolations, violation =>
violation.nodes.map(node => `${node.failureSummary} [${violation.id}]`)
Expand All @@ -89,6 +89,17 @@ async function toValidateA11y(this: jest.MatcherUtils, element: HTMLElement) {
return { pass, message: generateMessage };
}

function getAxeViolations(result: Axe.AxeResults) {
return ignoreAriaRequiredChildren([...result.violations, ...result.incomplete]);
}

// As per https://accessibilityinsights.io/info-examples/web/aria-required-children,
// some roles require children. For example, a role="listbox" requires "group" or "option" children.
// However, we are currently using empty list elements with aria-description pointing to the footer status text.
function ignoreAriaRequiredChildren(axeViolations: Axe.Result[]) {
return axeViolations.filter(result => result.id !== 'aria-required-children');
}

expect.extend({
toValidateA11y,
});
212 changes: 212 additions & 0 deletions src/autosuggest/__tests__/autosuggest-dropdown-states.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import * as React from 'react';
import { render } from '@testing-library/react';

import '../../__a11y__/to-validate-a11y';
import Autosuggest, { AutosuggestProps } from '../../../lib/components/autosuggest';
import createWrapper from '../../../lib/components/test-utils/dom';

import styles from '../../../lib/components/autosuggest/styles.css.js';

const defaultOptions: AutosuggestProps.Options = [
{ value: '1', label: 'One' },
{ value: '2', lang: 'Two' },
];
const defaultProps: AutosuggestProps = {
ariaLabel: 'search',
enteredTextLabel: text => `Use value: ${text}`,
value: '',
onChange: () => {},
options: defaultOptions,
empty: 'No options',
loadingText: 'loading...',
finishedText: 'finished!',
errorText: 'error!',
errorIconAriaLabel: 'error icon',
clearAriaLabel: 'clear input',
};

function renderAutosuggest(props: Partial<AutosuggestProps>) {
const { container } = render(<Autosuggest {...defaultProps} {...props} />);
const wrapper = createWrapper(container).findAutosuggest()!;
return { container, wrapper };
}

function focusInput() {
createWrapper().findAutosuggest()!.focus();
}

function expectDropdown() {
const wrapper = createWrapper().findAutosuggest()!;
expect(wrapper.findDropdown().findOpenDropdown()).not.toBe(null);
expect(wrapper.findNativeInput().getElement()).toHaveAttribute('aria-expanded', 'true');
}

function expectNoFooter() {
expect(createWrapper().findAutosuggest()!.findDropdown().findFooterRegion()).toBe(null);
}

function expectFooterSticky(isSticky: boolean) {
const dropdown = createWrapper().findAutosuggest()!.findDropdown()!.findOpenDropdown()!;
expect(Boolean(dropdown.findByClassName(styles['list-bottom']))).toBe(!isSticky);
}

function expectFooterContent(expectedText: string) {
const wrapper = createWrapper().findAutosuggest()!;
expect(wrapper.findDropdown().findFooterRegion()!).not.toBe(null);
expect(wrapper.findDropdown().findFooterRegion()!.getElement()).toHaveTextContent(expectedText);
expect(wrapper.findDropdown().find('ul')!.getElement()).toHaveAccessibleDescription(expectedText);
}

function expectFooterImage(expectedText: string) {
const footer = createWrapper().findAutosuggest()!.findDropdown().findFooterRegion()!;
expect(footer).not.toBe(null);
expect(footer.find('[role="img"]')!.getElement()).toHaveAccessibleName(expectedText);
}

async function expectA11y() {
const wrapper = createWrapper().findAutosuggest()!;
await expect(wrapper.getElement()).toValidateA11y();
}

describe('footer types', () => {
test('empty', async () => {
renderAutosuggest({ options: [] });
focusInput();
expectDropdown();
expectFooterSticky(true);
expectFooterContent('No options');
await expectA11y();
});

test('pending', async () => {
renderAutosuggest({ statusType: 'pending' });
focusInput();
expectDropdown();
expectNoFooter();
await expectA11y();
});

test('loading', async () => {
renderAutosuggest({ statusType: 'loading' });
focusInput();
expectDropdown();
expectFooterSticky(true);
expectFooterContent('loading...');
await expectA11y();
});

test('error', async () => {
renderAutosuggest({ statusType: 'error' });
focusInput();
expectDropdown();
expectFooterSticky(true);
expectFooterContent('error!');
expectFooterImage('error icon');
await expectA11y();
});

test('finished', async () => {
renderAutosuggest({ statusType: 'finished' });
focusInput();
expectDropdown();
expectFooterSticky(false);
expectFooterContent('finished!');
await expectA11y();
});

test('results', async () => {
renderAutosuggest({ value: 'x', filteringResultsText: () => '3 items' });
focusInput();
expectDropdown();
expectFooterSticky(true);
expectFooterContent('3 items');
await expectA11y();
});
});

describe('filtering results', () => {
describe('with empty state', () => {
test('displays empty state footer when value is empty', () => {
renderAutosuggest({ options: [], filteringResultsText: () => '0 items' });
focusInput();
expectFooterContent('No options');
});

test('displays results footer when value is not empty', () => {
renderAutosuggest({ value: ' ', options: [], filteringResultsText: () => '0 items' });
focusInput();
expectFooterContent('0 items');
});
});

describe('with pending state', () => {
test('displays no footer when value is empty', () => {
renderAutosuggest({ statusType: 'pending', filteringResultsText: () => '3 items' });
focusInput();
expectNoFooter();
});

test('displays results footer when value is not empty', () => {
renderAutosuggest({ value: ' ', statusType: 'pending', filteringResultsText: () => '3 items' });
focusInput();
expectFooterContent('3 items');
});
});

describe('with loading state', () => {
test('displays loading footer when value is empty', () => {
renderAutosuggest({ statusType: 'loading', filteringResultsText: () => '3 items' });
focusInput();
expectFooterContent('loading...');
});

test('displays loading footer when value is not empty', () => {
renderAutosuggest({ value: ' ', statusType: 'loading', filteringResultsText: () => '3 items' });
focusInput();
expectFooterContent('loading...');
});
});

describe('with error state', () => {
test('displays error footer when value is empty', () => {
renderAutosuggest({ statusType: 'error', filteringResultsText: () => '3 items' });
focusInput();
expectFooterContent('error!');
});

test('displays error footer when value is not empty', () => {
renderAutosuggest({ value: ' ', statusType: 'error', filteringResultsText: () => '3 items' });
focusInput();
expectFooterContent('error!');
});
});

describe('with finished state', () => {
test('displays no footer when finished w/o finished text and value is empty', () => {
renderAutosuggest({ finishedText: undefined, filteringResultsText: () => '3 items' });
focusInput();
expectNoFooter();
});

test('displays finished footer when finished w/ finished text and value is empty', () => {
renderAutosuggest({ filteringResultsText: () => '3 items' });
focusInput();
expectFooterContent('finished!');
});

test('displays results footer when finished w/o finished text and value is not empty', () => {
renderAutosuggest({ value: ' ', finishedText: undefined, filteringResultsText: () => '3 items' });
focusInput();
expectFooterContent('3 items');
});

test('displays results footer when finished w/ finished text and value is not empty', () => {
renderAutosuggest({ value: ' ', filteringResultsText: () => '3 items' });
focusInput();
expectFooterContent('3 items');
});
});
});
Loading

0 comments on commit 4284824

Please sign in to comment.