Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: combobox's new feature, autocomplete with typeahead #16904

Closed
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,9 @@ Map {
"autoAlign": Object {
"type": "bool",
},
"autocomplete": Object {
"type": "bool",
},
"className": Object {
"type": "string",
},
Expand Down
65 changes: 65 additions & 0 deletions packages/react/src/components/ComboBox/ComboBox-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -439,4 +439,69 @@ describe('ComboBox', () => {
);
});
});
describe('ComboBox autocomplete', () => {
const items = [
{ id: 'option-1', text: 'Option 1' },
{ id: 'option-2', text: 'Option 2' },
{ id: 'option-3', text: 'Option 3' },
];

const mockProps = {
id: 'test-combobox',
items,
itemToString: (item) => (item ? item.text : ''),
onChange: jest.fn(),
};

it('should respect autocomplete prop', async () => {
render(<ComboBox {...mockProps} autocomplete />);
await waitForPosition();
const inputNode = findInputNode();
expect(inputNode).toHaveAttribute('autocomplete');
});
it('should use autocompleteCustomFilter when autocomplete prop is true', async () => {
render(<ComboBox {...mockProps} autocomplete />);

// Open the dropdown
const input = screen.getByRole('combobox');
fireEvent.click(input);

// Type 'op' which should match all options
await userEvent.type(input, 'op');
expect(screen.getAllByRole('option')).toHaveLength(3);

// Type 'opt' which should still match all options
await userEvent.type(input, 't');
expect(screen.getAllByRole('option')).toHaveLength(3);

// Type 'opti' which should match only 'Option 1'
await userEvent.type(input, 'i');
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(screen.getByText('Option 1')).toBeInTheDocument();
});

it('should use default filter when autocomplete prop is false', async () => {
render(<ComboBox {...mockProps} />);

// Open the dropdown
const input = screen.getByRole('combobox');
fireEvent.click(input);

// Type 'op' which should match all options
await userEvent.type(input, 'op');
expect(screen.getAllByRole('option')).toHaveLength(3);

// Type 'opt' which should still match all options
await userEvent.type(input, 't');
expect(screen.getAllByRole('option')).toHaveLength(3);

// Type 'opti' which should still match all options
await userEvent.type(input, 'i');
expect(screen.getAllByRole('option')).toHaveLength(3);

// Type 'option' which should still match all options
await userEvent.type(input, 'on');
expect(screen.getAllByRole('option')).toHaveLength(3);
});
});
});
20 changes: 20 additions & 0 deletions packages/react/src/components/ComboBox/ComboBox.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AILabel, AILabelContent, AILabelActions } from '../AILabel';
import { IconButton } from '../IconButton';
import { View, FolderOpen, Folders } from '@carbon/icons-react';
import mdx from './ComboBox.mdx';
import { on } from 'process';

const items = [
{
Expand Down Expand Up @@ -132,6 +133,21 @@ export const AllowCustomValue = (args) => {
</div>
);
};
export const AutocompleteWithTypeahead = (args) => {
return (
<div style={{ width: 300 }}>
<ComboBox
allowCustomValue
autocomplete
onChange={args.onChange}
id="carbon-combobox"
items={['Apple', 'Orange', 'Banana', 'Pineapple', 'Raspberry', 'Lime']}
titleText="ComboBox title"
helperText="Combobox helper text"
/>
</div>
);
};
export const ExperimentalAutoAlign = () => (
<div style={{ width: 400 }}>
<div style={{ height: 300 }}></div>
Expand All @@ -152,6 +168,10 @@ AllowCustomValue.argTypes = {
onChange: { action: 'onChange' },
};

AutocompleteWithTypeahead.argTypes = {
onChange: { action: 'onChange' },
};

export const _WithLayer = () => (
<WithLayer>
{(layer) => (
Expand Down
171 changes: 169 additions & 2 deletions packages/react/src/components/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ interface OnChangeData<ItemType> {
selectedItem: ItemType | null | undefined;
inputValue?: string | null;
}
const autocompleteCustomFilter = (menu) => {
if (
!menu ||
typeof menu.item !== 'string' ||
typeof menu.inputValue !== 'string'
) {
return false;
}
const item = menu.item.toLowerCase();
const input = menu.inputValue.toLowerCase();

/**
* Message ids that will be passed to translateWithId().
Expand Down Expand Up @@ -172,6 +182,10 @@ export interface ComboBoxProps<ItemType>
*/
autoAlign?: boolean;

/**
* **Experimental**: will enable autcomplete and typeahead for the input field
*/
autocomplete?: boolean;
/**
* An optional className to add to the container node
*/
Expand Down Expand Up @@ -351,6 +365,7 @@ const ComboBox = forwardRef(
['aria-label']: ariaLabel = 'Choose an item',
ariaLabel: deprecatedAriaLabel,
autoAlign = false,
autocomplete = false,
className: containerClassName,
direction = 'bottom',
disabled = false,
Expand Down Expand Up @@ -422,6 +437,54 @@ const ComboBox = forwardRef(
const [doneInitialSelectedItem, setDoneInitialSelectedItem] =
useState(false);
const savedOnInputChange = useRef(onInputChange);
const [typeaheadText, setTypeaheadText] = useState('');
const [cursorPosition, setCursorPosition] = useState(0);

useEffect(() => {
const getTypeaheadSuggestion = (input: string) => {
if (!autocomplete || !input) {
return '';
}

const filteredItems = items.filter((item) =>
autocompleteCustomFilter({
item: itemToString(item),
inputValue: input,
})
);

if (filteredItems.length > 0) {
const suggestion = itemToString(filteredItems[0]);
return suggestion.slice(input.length);
}
return '';
};

if (autocomplete) {
const suggestion = getTypeaheadSuggestion(inputValue);
setTypeaheadText(suggestion);
}
}, [inputValue, autocomplete, items, itemToString]);

// Modify getInputProps to include custom handlers
// const getCustomInputProps = (downshiftGetInputProps) => {
// const inputProps = downshiftGetInputProps();
// return {
// ...inputProps,
// onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => {
// inputProps.onKeyDown(event);
// if (autocomplete && event.key === 'Tab' && typeaheadText) {
// event.preventDefault();
// setInputValue((prevValue) => prevValue + typeaheadText);
// setTypeaheadText('');
// }
// },
// onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
// inputProps.onChange(event);
// setCursorPosition(event.target.selectionStart || 0);
// },
// };
// };

if (!doneInitialSelectedItem || prevSelectedItem !== selectedItemProp) {
setDoneInitialSelectedItem(true);
Expand All @@ -442,7 +505,9 @@ const ComboBox = forwardRef(
inputValue: string | null
) =>
items.filter((item) =>
shouldFilterItem
autocomplete
? autocompleteCustomFilter({ item: itemToString(item), inputValue })
: shouldFilterItem
? shouldFilterItem({
item,
itemToString,
Expand Down Expand Up @@ -772,7 +837,7 @@ const ComboBox = forwardRef(
warnText={warnText}
warnTextId={warnTextId}>
<div className={`${prefix}--list-box__field`}>
<input
{/* <input
disabled={disabled}
className={inputClasses}
type="text"
Expand Down Expand Up @@ -857,7 +922,104 @@ const ComboBox = forwardRef(
{...readOnlyEventHandlers}
readOnly={readOnly}
aria-describedby={ariaDescribedBy}
/> */}
<input
{...getInputProps({
'aria-controls': isOpen ? undefined : menuProps.id,
placeholder,
ref: mergeRefs(textInput, ref),
onKeyDown: (
event: KeyboardEvent<HTMLInputElement> & {
preventDownshiftDefault: boolean;
target: {
value: string;
setSelectionRange: (start: number, end: number) => void;
};
}
): void => {
if (match(event, keys.Space)) {
event.stopPropagation();
}
if (
match(event, keys.Enter) &&
(!inputValue || allowCustomValue)
) {
toggleMenu();
if (highlightedIndex !== -1) {
selectItem(
filterItems(items, itemToString, inputValue)[
highlightedIndex
]
);
}
event.preventDownshiftDefault = true;
event?.persist?.();
}
if (match(event, keys.Escape) && inputValue) {
if (event.target === textInput.current && isOpen) {
toggleMenu();
event.preventDownshiftDefault = true;
event?.persist?.();
}
}
if (match(event, keys.Home) && event.code !== 'Numpad7') {
event.target.setSelectionRange(0, 0);
}
if (match(event, keys.End) && event.code !== 'Numpad1') {
event.target.setSelectionRange(
event.target.value.length,
event.target.value.length
);
}
if (event.altKey && event.key == 'ArrowDown') {
event.preventDownshiftDefault = true;
if (!isOpen) {
toggleMenu();
}
}
if (event.altKey && event.key == 'ArrowUp') {
event.preventDownshiftDefault = true;
if (isOpen) {
toggleMenu();
}
}
// Add typeahead completion on Tab
if (autocomplete && event.key === 'Tab' && typeaheadText) {
event.preventDefault();
setInputValue((prevValue) => prevValue + typeaheadText);
setTypeaheadText('');
}
},
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setCursorPosition(event.target.selectionStart || 0);
},
})}
disabled={disabled}
className={inputClasses}
type="text"
tabIndex={0}
aria-haspopup="listbox"
title={textInput?.current?.value}
{...rest}
{...readOnlyEventHandlers}
readOnly={readOnly}
aria-describedby={ariaDescribedBy}
/>
{autocomplete && typeaheadText && (
<div
// eslint-disable-next-line react/forbid-dom-props
style={{
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none',
color: 'gray',
zIndex: 1,
paddingLeft: `${cursorPosition * 8}px`, // Adjust based on your font
}}>
{typeaheadText}
</div>
)}

{invalid && (
<WarningFilled className={`${prefix}--list-box__invalid-icon`} />
Expand Down Expand Up @@ -975,6 +1137,11 @@ ComboBox.propTypes = {
*/
autoAlign: PropTypes.bool,

/**
* **Experimental**: will enable autcomplete and typeahead for the input field
*/
autocomplete: PropTypes.bool,

/**
* An optional className to add to the container node
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/ListBox/test-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

const prefix = 'cds';
import userEvent from '@testing-library/user-event';
import { act } from 'react';
import { act } from '@testing-library/react';

// Finding nodes in a ListBox
export const findListBoxNode = () => {
Expand Down
Loading