Skip to content

Commit

Permalink
feat: fix and update autosuggest (#99)
Browse files Browse the repository at this point in the history
* feat: fix and update autosuggest

* Update index.scss

* test: add some tests

* chore: remove unused import

* fix: ignore export

* fix: weird menu option highlighted behavior
  • Loading branch information
timrbula authored Jan 27, 2023
1 parent 5a19f47 commit c20ddc0
Show file tree
Hide file tree
Showing 12 changed files with 213 additions and 146 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@boomerang-io/carbon-addons-boomerang-react",
"description": "Carbon Addons for Boomerang apps",
"version": "4.0.1",
"version": "4.0.2-beta.0",
"author": {
"name": "Tim Bula",
"email": "timrbula@gmail.com"
Expand Down Expand Up @@ -98,6 +98,7 @@
"@types/lodash.isequal": "^4.5.6",
"@types/lodash.sortby": "^4.7.7",
"@types/react": "^16.9.49",
"@types/react-autosuggest": "^10.1.6",
"@types/react-dom": "^16.9.8",
"@types/react-modal": "^3.13.1",
"@types/react-router-dom": "5.3.3",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 40 additions & 0 deletions src/components/AutoSuggest/AutoSuggest.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from "react";
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import AutoSuggest from "./AutoSuggest";
import TextInput from "../TextInput";
import { animals } from "./AutoSuggest.stories";

const props = {
autoSuggestions: animals,
children: <TextInput id="suggestions" labelText="Suggestions" />,
inputProps: {},
onChange: () => {},
};

describe("AutoSuggest", () => {
test("snapshot", () => {
const { baseElement } = render(<AutoSuggest {...props} />);
expect(baseElement).toMatchSnapshot();
});

test("functional", async () => {
render(<AutoSuggest {...props} />);
expect(await screen.findByLabelText("Suggestions")).toBeInTheDocument();
const input = screen.getByLabelText("Suggestions");
userEvent.type(input, "ca");
expect(await screen.findByText("caribou")).toBeInTheDocument();
expect(await screen.findByText("cat")).toBeInTheDocument();
const suggestion = screen.getByText("caribou");
await userEvent.click(suggestion);
expect(screen.queryByText("cat")).toBeNull();
});

test("a11y", async () => {
const { container } = render(<AutoSuggest {...props} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
8 changes: 5 additions & 3 deletions src/components/AutoSuggest/AutoSuggest.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { action } from "@storybook/addon-actions";
import TextInput from "../TextInput";
import AutoSuggest from "./AutoSuggest";

const animals = [
export const animals = [
{ label: "caribou", value: "caribou" },
{ label: "cat", value: "cat" },
{ label: "catfish", value: "catfish" },
Expand All @@ -24,11 +24,13 @@ export default {
parameters: {
docs: {
description: {
component: "An enhanced TextInput that supports selecting from a provided list of options.",
component:
"An enhanced TextInput that supports selecting from a provided list of options that enables suggestions per word in input. It is a wrapper around react-autosuggest",
},
},
},
decorators: [(story: any) => <div style={{ maxWidth: "25rem", minHeight: "20rem" }}>{story()}</div>],
excludeStories: /animals.*/,
argTypes: {
autoSuggestions: { control: "array", defaultValue: animals },
inputProps: {
Expand All @@ -45,7 +47,7 @@ export default {
export const Default = (args) => {
return (
<AutoSuggest onChange={action("Auto suggest change")} {...args}>
<TextInput id="auto-suggest"/>
<TextInput id="auto-suggest" />
</AutoSuggest>
);
};
137 changes: 86 additions & 51 deletions src/components/AutoSuggest/AutoSuggest.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
import React, { Component } from "react";
import AutoSuggest, { ChangeEvent, RenderSuggestionsContainerParams } from "react-autosuggest";
import { matchSorter as ms } from "match-sorter";
import AutoSuggestInput, { AutoSuggestInputProps } from "./AutoSuggestInput";
import { prefix } from "../../internal/settings";

const SELECT_METHODS = ["up", "down", "click"];

type Suggestion = { label: string; value: string };
interface Suggestion {
label: string;
value: string;
}

interface Props {
// Omit the functions we define in the component itself
type AutoSuggestProps = Omit<
AutoSuggest.AutosuggestPropsBase<Suggestion>,
"getSuggestionValue" | "onSuggestionsFetchRequested" | "renderSuggestion" | "inputProps"
> & {
autoSuggestions: Suggestion[];
children: React.ReactElement<any, string | React.JSXElementConstructor<any>>;
children: React.ReactElement;
initialValue?: string;
inputProps?: AutoSuggestInputProps;
onChange?: (newValue: any) => any;
}
onChange: (newValue: string) => void;
inputProps: any;
};

type State = {
interface AutoSuggestState {
value: string;
caretIndex: number;
suggestions: any[];
};
suggestions: Suggestion[];
}

class AutoSuggest extends Component<Props, State> {
inputRef = React.createRef();
class AutoSuggestBmrg extends Component<AutoSuggestProps, AutoSuggestState> {
inputRef = React.createRef<HTMLInputElement>();

state = {
// Used if we want to have some of the functions external instead of
Expand All @@ -33,97 +41,124 @@ class AutoSuggest extends Component<Props, State> {

// Each time the component updates we want to refocus the input and keep the cursor in the correct place
// Needed for when cycling through mutliple suggestions with the arrow keys and the cursor resets to the end of the input. We don't want that.
componentDidUpdate(prevProps: Props, prevState: State) {
componentDidUpdate(_: AutoSuggestProps, prevState: AutoSuggestState) {
if (this.state.value && prevState.value !== this.state.value) {
// prevent it from focusing on initial render / empty
(this as any).inputRef.current?.focus();
(this as any).inputRef.current?.setSelectionRange(this.state.caretIndex, this.state.caretIndex);
this.inputRef.current?.focus();
this.inputRef.current?.setSelectionRange(this.state.caretIndex, this.state.caretIndex);
}
}

renderSuggestion = (suggestion: Suggestion) => <div>{suggestion.label}</div>;
renderSuggestion = (suggestion: Suggestion) => suggestion.label;

onSuggestionsFetchRequested = () => {
this.setState(() => ({
suggestions: this.getSuggestions(),
}));
};

/**
* More logic here for handling a user cycling through suggestions
* Move the caret to the new suggestion location or use the reference to the DOM element.
* Shift based on the change in length of the value b/c of different length suggestions
*/

onInputChange = (_: any, { newValue, method }: any) => {
this.setState((prevState: any) => ({
onInputChange = (event: React.FormEvent<HTMLElement>, { newValue, method }: ChangeEvent) => {
this.setState((prevState: AutoSuggestState) => ({
value: newValue,
caretIndex: SELECT_METHODS.includes(method)
? prevState.caretIndex + (newValue.length - prevState.value.length)
: (this as any).inputRef.current.selectionStart,
: this.inputRef.current?.selectionStart ?? 0,
}));
if (typeof this.props.onChange === "function") {
this.props.onChange(newValue);
}
this.props.onChange(newValue);
};

/**
* Return the new value for the input
* - Find the current caret position
* - get the string up to that point
* - find the last word (space-delimited) and replace it in input
* -
* Find the current caret position
* get the string up to that point
* find the last word (space-delimited) and replace it in input
*/
getSuggestionValue = (suggestion: Suggestion) => {
const inputWords = this.findWordsBeforeCurrentLocation();
// const propertySuggestion = `\${p:${suggestion}}`;
const substringWordList = this.findWordsBeforeCurrentLocation();

/*
* Find the position of the caret, get the string up to that point
* and find the index of the last word - i.e. the one the user entered
* and find the index of the last word in that substring
* This gives use the word to suggest matches for
*/
const pos = this.state.value.slice(0, this.state.caretIndex).lastIndexOf(inputWords[inputWords.length - 1]);
const closestWord = substringWordList.at(-1) as string;
const position = this.state.value.slice(0, this.state.caretIndex).lastIndexOf(closestWord);

// Sub in the new property suggestion
return (
this.state.value.substring(0, pos) +
this.state.value.substring(0, position) +
suggestion.value +
this.state.value.substring(pos + inputWords[inputWords.length - 1].length)
this.state.value.substring(position + closestWord.length)
);
};

getSuggestions = () => {
const inputWords = this.findWordsBeforeCurrentLocation();
const substringWordList = this.findWordsBeforeCurrentLocation();

// Prevent empty string from matching everyhing
const inputWord = inputWords.length ? inputWords[inputWords.length - 1] : "";
return !inputWord
const closestWord = substringWordList.at(-1);
return !closestWord
? []
: ms(this.props.autoSuggestions, inputWord, {
: ms(this.props.autoSuggestions, closestWord, {
// Use match-sorter for matching inputs
keys: [{ key: "value" }],
});
};

// Get array of distinct words prior to the current location of entered text
// Use the inputRef instead of state becuase of asnychronous updating of state and calling of these functions :(
findWordsBeforeCurrentLocation() {
return (this as any).inputRef.current.value.slice(0, (this as any).inputRef.current.selectionStart).split(" ");
}
findWordsBeforeCurrentLocation = () => {
return this.inputRef.current?.value
.slice(0, this.inputRef.current?.selectionStart ?? undefined)
.split(" ") as string[];
};

onSuggestionsClearRequested = () => {
this.setState({
suggestions: [],
});
};

render() {
const { inputProps, children, ...rest } = this.props;

const finalInputProps = {
...inputProps,
onChange: this.onInputChange,
value: this.state.value,
...inputProps,
};

return (
<AutoSuggestInput
getSuggestionValue={this.getSuggestionValue}
inputProps={finalInputProps}
renderSuggestion={this.renderSuggestion}
suggestions={this.state.suggestions}
focusInputOnSuggestionClick={false}
{...rest}
>
{(inputProps: AutoSuggestInputProps) => React.cloneElement(children, { ...inputProps, ref: this.inputRef })}
</AutoSuggestInput>
<div className={`${prefix}--bmrg-auto-suggest`}>
<AutoSuggest
getSuggestionValue={this.getSuggestionValue}
inputProps={finalInputProps}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
renderInputComponent={(props) => React.cloneElement(children, { ...props, ref: this.inputRef })}
renderSuggestion={this.renderSuggestion}
renderSuggestionsContainer={renderSuggestionsContainer}
suggestions={this.state.suggestions}
{...rest}
/>
</div>
);
}
}

export default AutoSuggest;
// Needed to add aria-label for a11y
function renderSuggestionsContainer({ containerProps, children, ...rest }: RenderSuggestionsContainerParams) {
return (
<div aria-label="AutoSuggest listbox" {...containerProps} {...rest}>
{children}
</div>
);
}

export default AutoSuggestBmrg;
Loading

0 comments on commit c20ddc0

Please sign in to comment.