Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions src/tedi/components/form/select/components/select-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,30 @@ import { components as ReactSelectComponents, InputProps } from 'react-select';
import { ISelectOption } from '../select';
import styles from '../select.module.scss';

export const SelectInput = (props: InputProps<ISelectOption, boolean>): JSX.Element => (
<ReactSelectComponents.Input
{...props}
className={cn(props.className, styles['tedi-select__input'])}
isHidden={props.selectProps.inputIsHidden !== undefined ? props.selectProps.inputIsHidden : props.isHidden}
aria-required={props.selectProps.required}
required={props.selectProps.required}
/>
);
export const SelectInput = (props: InputProps<ISelectOption, boolean>): JSX.Element => {
// react-select points the combobox's `aria-describedby` at its own placeholder
// element, so screen readers announce the placeholder as the field's
// description (placeholder should be visual only). Drop that reference, keep any
// other react-select description (e.g. its live-region), and append the field's
// helper/error text so it's actually announced.
const helperDescribedBy = (props.selectProps as { 'aria-describedby'?: string })['aria-describedby'];
const describedBy =
[
...(props['aria-describedby']?.split(' ') ?? []).filter((token) => token && !token.endsWith('-placeholder')),
helperDescribedBy,
]
.filter(Boolean)
.join(' ') || undefined;

return (
<ReactSelectComponents.Input
{...props}
className={cn(props.className, styles['tedi-select__input'])}
isHidden={props.selectProps.inputIsHidden !== undefined ? props.selectProps.inputIsHidden : props.isHidden}
aria-required={props.selectProps.required}
required={props.selectProps.required}
aria-describedby={describedBy}
inputMode={props.selectProps.softKeyboardSuppressed ? 'none' : undefined}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const SelectMultiValueRemove = ({ innerProps, data }: MultiValueRemovePro
onKeyDown={handleKeyDown}
className={styles['tedi-select__multi-value-clear']}
iconSize={18}
title={`${getLabel('clear')} ${data.label}`}
title={`${getLabel('remove')} ${typeof data.label === 'string' ? data.label : data.value}`}
/>
<Separator color="primary" axis="vertical" className={styles['tedi-select__separator']} />
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import cn from 'classnames';
import { MultiValueProps } from 'react-select';

import { useLabels } from '../../../../providers/label-provider';
import { Tag, TagEllipsis } from '../../../tags/tag/tag';
import { ISelectOption } from '../select';
import styles from '../select.module.scss';
Expand Down Expand Up @@ -47,6 +48,7 @@ export const SelectMultiValue = ({
removeProps,
...props
}: MultiValueType): JSX.Element | null => {
const { getLabel } = useLabels();
const { isSingleRow, visibleCount } = useSelectTagsContext();

if (props.data.value === SELECT_ALL_VALUE || isGroupSentinel(props.data)) {
Expand Down Expand Up @@ -88,6 +90,11 @@ export const SelectMultiValue = ({
isTagRemovable
? {
tabIndex: 0,
// Name the button "Remove <option>" so screen readers say which
// tag is removed, instead of a bare "Close".
title: `${getLabel('remove')} ${
typeof props.data.label === 'string' ? props.data.label : props.data.value
}`,
onMouseDown: (event) => event.stopPropagation(),
onKeyDown: handleCloseKeyDown,
}
Expand Down
16 changes: 4 additions & 12 deletions src/tedi/components/form/select/select.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ div.tedi-select__control {
color: var(--form-input-text-filled);
}

.tedi-select__control :global(.select__placeholder) {
color: var(--form-input-text-placeholder);
}

.tedi-select--invalid .tedi-select__control {
border-color: var(--form-general-feedback-error-border);

Expand Down Expand Up @@ -254,18 +258,6 @@ div.tedi-select__multi-value-item {
margin: -0.125rem -0.25rem -0.125rem 0;
}

:global {
.tedi-select__value-container {
gap: var(--form-field-inner-spacing);
padding: 0 !important;

.tedi-select__placeholder {
color: var(--form-input-text-placeholder);
pointer-events: none;
}
}
}

.tedi-select--tags-row {
:global .select__control > .select__value-container--is-multi {
flex-wrap: nowrap;
Expand Down
129 changes: 127 additions & 2 deletions src/tedi/components/form/select/select.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import React from 'react';

import { UnknownType } from '../../../types/commonTypes';
import InputGroup from '../input-group/input-group';
import { SelectMultiValueRemove } from './components/select-multi-value-remove';
import { ISelectOption, Select, SelectProps } from './select';

Expand Down Expand Up @@ -341,7 +342,7 @@ describe('Select component', () => {
it('stops event propagation on mouse down when closing a removable tag', () => {
const stopPropagationSpy = jest.spyOn(Event.prototype, 'stopPropagation');
render(<Select {...defaultProps} multiple value={[basicOptions[0]]} isTagRemovable />);
const closeButton = screen.getByRole('button', { name: /close/i });
const closeButton = screen.getByRole('button', { name: /remove/i });

fireEvent.mouseDown(closeButton);
expect(stopPropagationSpy).toHaveBeenCalled();
Expand Down Expand Up @@ -567,7 +568,7 @@ describe('Select component', () => {
/>
);

const closeButtons = screen.getAllByRole('button', { name: /close/i });
const closeButtons = screen.getAllByRole('button', { name: /remove/i });
await act(async () => {
await userEvent.click(closeButtons[0]);
});
Expand Down Expand Up @@ -708,4 +709,128 @@ describe('Select component', () => {
});
});
});

describe('accessibility', () => {
it('associates the label with the combobox even without an explicit id', () => {
// Regression: `inputId`/`instanceId` used the raw `id` prop, so with no id
// the input became "undefined-input" and no longer matched the label.
render(<Select label="Choose a fruit" options={basicOptions} onChange={jest.fn()} />);
expect(screen.getByRole('combobox')).toHaveAccessibleName('Choose a fruit');
});

it('describes the combobox with the helper text, not the placeholder', () => {
render(
<Select
{...defaultProps}
placeholder="Pick one"
helper={{ id: 'fruit-help', text: 'Only ripe fruit', type: 'error' }}
/>
);

const combobox = screen.getByRole('combobox');
expect(combobox).toHaveAccessibleDescription('Only ripe fruit');
// react-select's placeholder must not describe the field.
expect(combobox.getAttribute('aria-describedby') ?? '').not.toMatch(/-placeholder/);
});

it('gives each tag remove button an accessible name including the option label', () => {
render(<Select {...defaultProps} multiple value={[{ value: 'apple', label: 'Apple' }]} isTagRemovable />);
expect(screen.getByRole('button', { name: /remove Apple/i })).toBeInTheDocument();
});

it('keeps the combobox named by the label of a surrounding labeled InputGroup', () => {
// Regression: the group renders <label htmlFor={inputGroup.inputId}> and Select
// hides its own label, so the combobox input (not the container) must own that id.
render(
<InputGroup id="fruit-group" label="Choose a fruit">
<Select options={basicOptions} onChange={jest.fn()} />
</InputGroup>
);

const combobox = screen.getByRole('combobox');
expect(combobox).toHaveAccessibleName('Choose a fruit');
expect(combobox).toHaveAttribute('id', 'fruit-group');
});
});

describe('openKeyboardOnTouch (mobile soft keyboard)', () => {
const getControl = (combobox: HTMLElement) => combobox.closest('.tedi-select__control') as HTMLElement;

// jsdom's synthetic PointerEvent doesn't reliably carry `pointerType`, so set
// it explicitly on the native event React reads from.
const firePointerDown = (el: Element, pointerType: 'touch' | 'pen' | 'mouse') => {
const event = new Event('pointerdown', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'pointerType', { value: pointerType });
fireEvent(el, event);
};

it('does not suppress the soft keyboard by default', () => {
render(<Select {...defaultProps} />);
const combobox = screen.getByRole('combobox');
firePointerDown(getControl(combobox), 'touch');
expect(combobox).not.toHaveAttribute('inputmode', 'none');
});

it('suppresses the soft keyboard when opened by touch and openKeyboardOnTouch is false', () => {
render(<Select {...defaultProps} openKeyboardOnTouch={false} />);
const combobox = screen.getByRole('combobox');
firePointerDown(getControl(combobox), 'touch');
// inputMode="none" keeps the on-screen keyboard down while browsing.
expect(combobox).toHaveAttribute('inputmode', 'none');
});

it('raises the keyboard again once the user taps the search input directly', () => {
render(<Select {...defaultProps} openKeyboardOnTouch={false} />);
const combobox = screen.getByRole('combobox');

firePointerDown(getControl(combobox), 'touch');
expect(combobox).toHaveAttribute('inputmode', 'none');

firePointerDown(combobox, 'touch');
expect(combobox).not.toHaveAttribute('inputmode', 'none');
});

it('never suppresses the keyboard for mouse users, even when openKeyboardOnTouch is false', () => {
render(<Select {...defaultProps} openKeyboardOnTouch={false} />);
const combobox = screen.getByRole('combobox');
firePointerDown(getControl(combobox), 'mouse');
expect(combobox).not.toHaveAttribute('inputmode', 'none');
});

it('resets the suppression when the field loses focus', () => {
render(<Select {...defaultProps} openKeyboardOnTouch={false} />);
const combobox = screen.getByRole('combobox');

firePointerDown(getControl(combobox), 'touch');
expect(combobox).toHaveAttribute('inputmode', 'none');

fireEvent.blur(combobox);
expect(combobox).not.toHaveAttribute('inputmode', 'none');
});

it('does not suppress the keyboard when the label or helper text is tapped', () => {
const { container } = render(
<Select {...defaultProps} openKeyboardOnTouch={false} helper={{ text: 'Pick a fruit', type: 'hint' }} />
);
const combobox = screen.getByRole('combobox');

const label = container.querySelector('label') as HTMLElement;
firePointerDown(label, 'touch');
expect(combobox).not.toHaveAttribute('inputmode', 'none');

firePointerDown(screen.getByText('Pick a fruit'), 'touch');
expect(combobox).not.toHaveAttribute('inputmode', 'none');
});

it('stops forwarding inputMode="none" once the feature is turned off, even if still suppressed', () => {
const { rerender } = render(<Select {...defaultProps} openKeyboardOnTouch={false} />);
const combobox = screen.getByRole('combobox');

firePointerDown(getControl(combobox), 'touch');
expect(combobox).toHaveAttribute('inputmode', 'none');

rerender(<Select {...defaultProps} openKeyboardOnTouch={true} />);
expect(combobox).not.toHaveAttribute('inputmode', 'none');
});
});
});
44 changes: 25 additions & 19 deletions src/tedi/components/form/select/select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,6 @@ const options = [
{ value: 'haapsalu', label: 'Haapsalu' },
];

const groupedOptions: OptionsOrGroups<ISelectOption, IGroupedOptions<ISelectOption>> = [
{
label: 'American cities',
options: [
{ value: 'new-york', label: 'New York' },
{ value: 'dallas', label: 'Dallas' },
],
},
{
label: 'Estonian cities',
options: [
{ value: 'tallinn', label: 'Tallinn' },
{ value: 'tartu', label: 'Tartu' },
],
},
];

const TemplateSizes: StoryFn = (args) => (
<Row>
<Col lg={12} xs={12} className="example-list">
Expand All @@ -58,15 +41,15 @@ const TemplateSizes: StoryFn = (args) => (
<Text modifiers="bold">Default</Text>
</Col>
<Col lg={10} xs={12}>
<Select label={args.label} id="select-size-default" {...args} />
<Select {...args} id="select-size-default" />
</Col>
</Row>
<Row className="padding-14-16">
<Col lg={2} xs={12} className="display-flex align-items-center">
<Text modifiers="bold">Small</Text>
</Col>
<Col lg={10} xs={12}>
<Select label={args.label} size="small" id="select-size-default" {...args} />
<Select {...args} size="small" id="select-size-small" />
</Col>
</Row>
</Col>
Expand Down Expand Up @@ -743,3 +726,26 @@ export const EditableSelect: Story = {
label: 'Editable label',
},
};

/**
* **Mobile keyboard deferral.** For quick-pick searchable selects, the on-screen
* keyboard popping up the instant the menu opens can cover most of the screen.
* With `openKeyboardOnTouch={false}`, tapping the field on a touch/pen device
* opens the menu for browsing **without** raising the keyboard — the input is
* rendered with `inputMode="none"`. The keyboard appears only when the user
* taps the search input directly.
*
* This is touch-only: mouse and keyboard users are unaffected, and hardware
* typing is never blocked, so the combobox stays fully operable (WCAG 2.1.1).
* Best viewed on a real device or the Storybook mobile viewport.
*/
export const DeferKeyboardOnTouch: Story = {
args: {
id: 'defer-keyboard-example',
label: 'Address',
placeholder: 'Vali...',
options: options,
openKeyboardOnTouch: false,
isSearchable: true,
},
};
Loading
Loading