Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/react/IssueWizard.css b/static_src/react/IssueWizard.css
new file mode 100644
index 0000000..46b1ff2
--- /dev/null
+++ b/static_src/react/IssueWizard.css
@@ -0,0 +1,18 @@
+.container {
+  margin-left: 50px;
+  max-width: 70vw;
+  width: 100%;
+  font-family: 'Poppins', serif;
+}
+
+.yellowBox {
+  height: 10vh;
+  border-style: solid;
+  border-color: #ea8600;
+  border-radius: 8px;
+  background: #fef7e0;
+}
+
+.poppins {
+  font-family: 'Poppins', serif;
+}
\ No newline at end of file
diff --git a/static_src/react/IssueWizard.test.tsx b/static_src/react/IssueWizard.test.tsx
new file mode 100644
index 0000000..07016ce
--- /dev/null
+++ b/static_src/react/IssueWizard.test.tsx
@@ -0,0 +1,19 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {assert} from 'chai';
+import {render} from '@testing-library/react';
+
+import {IssueWizard} from './IssueWizard.tsx';
+
+describe('IssueWizard', () => {
+  it('renders', async () => {
+    render(<IssueWizard />);
+
+    const stepper = document.getElementById("mobile-stepper")
+
+    assert.isNotNull(stepper);
+  });
+});
diff --git a/static_src/react/IssueWizard.tsx b/static_src/react/IssueWizard.tsx
new file mode 100644
index 0000000..de5e8fb
--- /dev/null
+++ b/static_src/react/IssueWizard.tsx
@@ -0,0 +1,67 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {ReactElement} from 'react';
+import * as React from 'react'
+import ReactDOM from 'react-dom';
+import styles from './IssueWizard.css';
+import DotMobileStepper from './issue-wizard/DotMobileStepper.tsx';
+import LandingStep from './issue-wizard/LandingStep.tsx';
+import DetailsStep from './issue-wizard/DetailsStep.tsx'
+
+/**
+ * Base component for the issue filing wizard, wrapper for other components.
+ * @return Issue wizard JSX.
+ */
+export function IssueWizard(): ReactElement {
+  const [checkExisting, setCheckExisting] = React.useState(false);
+  const [userType, setUserType] = React.useState('End User');
+  const [activeStep, setActiveStep] = React.useState(0);
+  const [category, setCategory] = React.useState('');
+  const [textValues, setTextValues] = React.useState(
+    {
+      oneLineSummary: '',
+      stepsToReproduce: '',
+      describeProblem: '',
+      additionalComments: ''
+    });
+
+  let nextEnabled;
+  let page;
+  if (activeStep === 0){
+    page = <LandingStep
+        checkExisting={checkExisting}
+        setCheckExisting={setCheckExisting}
+        userType={userType}
+        setUserType={setUserType}
+        category={category}
+        setCategory={setCategory}
+        />;
+    nextEnabled = checkExisting && userType && (category != '');
+  } else if (activeStep === 1){
+    page = <DetailsStep textValues={textValues} setTextValues={setTextValues} category={category}/>;
+    nextEnabled = (textValues.oneLineSummary.trim() !== '') &&
+                  (textValues.stepsToReproduce.trim() !== '') &&
+                  (textValues.describeProblem.trim() !== '');
+  }
+
+  return (
+    <>
+      <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Poppins"></link>
+      <div className={styles.container}>
+        {page}
+        <DotMobileStepper nextEnabled={nextEnabled} activeStep={activeStep} setActiveStep={setActiveStep}/>
+      </div>
+    </>
+  );
+}
+
+/**
+ * Renders the issue filing wizard page.
+ * @param mount HTMLElement that the React component should be
+ *   added to.
+ */
+export function renderWizard(mount: HTMLElement): void {
+  ReactDOM.render(<IssueWizard />, mount);
+}
diff --git a/static_src/react/ReactAutocomplete.test.tsx b/static_src/react/ReactAutocomplete.test.tsx
new file mode 100644
index 0000000..a1e7c62
--- /dev/null
+++ b/static_src/react/ReactAutocomplete.test.tsx
@@ -0,0 +1,311 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import {assert} from 'chai';
+import React from 'react';
+import sinon from 'sinon';
+import {fireEvent, render} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import {ReactAutocomplete, MAX_AUTOCOMPLETE_OPTIONS}
+  from './ReactAutocomplete.tsx';
+
+/**
+ * Cleans autocomplete dropdown from the DOM for the next test.
+ * @param input The autocomplete element to remove the dropdown for.
+ */
+ const cleanAutocomplete = (input: ReactAutocomplete) => {
+  fireEvent.change(input, {target: {value: ''}});
+  fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+};
+
+xdescribe('ReactAutocomplete', () => {
+  it('renders', async () => {
+    const {container} = render(<ReactAutocomplete label="cool" options={[]} />);
+
+    assert.isNotNull(container.querySelector('input'));
+  });
+
+  it('placeholder renders', async () => {
+    const {container} = render(<ReactAutocomplete
+      placeholder="penguins"
+      options={['']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.placeholder, 'penguins');
+  });
+
+  it('filterOptions empty input value', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['option 1 label']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.value, '');
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+    assert.strictEqual(input?.value, '');
+  });
+
+  it('filterOptions truncates values', async () => {
+    const options = [];
+
+    // a0@test.com, a1@test.com, a2@test.com, ...
+    for (let i = 0; i <= MAX_AUTOCOMPLETE_OPTIONS; i++) {
+      options.push(`a${i}@test.com`);
+    }
+
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={options}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'a');
+
+    const results = document.querySelectorAll('.autocomplete-option');
+
+    assert.equal(results.length, MAX_AUTOCOMPLETE_OPTIONS);
+
+    // Clean up autocomplete dropdown from the DOM for the next test.
+    cleanAutocomplete(input);
+  });
+
+  it('filterOptions label matching', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['option 1 label']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.value, '');
+
+    userEvent.type(input, 'lab');
+    assert.strictEqual(input?.value, 'lab');
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+
+    assert.strictEqual(input?.value, 'option 1 label');
+  });
+
+  it('filterOptions description matching', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      getOptionDescription={() => 'penguin apples'}
+      options={['lol']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.value, '');
+
+    userEvent.type(input, 'app');
+    assert.strictEqual(input?.value, 'app');
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+    assert.strictEqual(input?.value, 'lol');
+  });
+
+  it('filterOptions no match', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={[]}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    assert.strictEqual(input?.value, '');
+
+    userEvent.type(input, 'foobar');
+    assert.strictEqual(input?.value, 'foobar');
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+    assert.strictEqual(input?.value, 'foobar');
+  });
+
+  it('onChange callback is called', async () => {
+    const onChangeStub = sinon.stub();
+
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={[]}
+      onChange={onChangeStub}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    sinon.assert.notCalled(onChangeStub);
+
+    userEvent.type(input, 'foobar');
+    sinon.assert.notCalled(onChangeStub);
+
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+    sinon.assert.calledOnce(onChangeStub);
+
+    assert.equal(onChangeStub.getCall(0).args[1], 'foobar');
+  });
+
+  it('onChange excludes fixed values', async () => {
+    const onChangeStub = sinon.stub();
+
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute owl']}
+      multiple={true}
+      fixedValues={['immortal penguin']}
+      onChange={onChangeStub}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+    fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+
+    sinon.assert.calledWith(onChangeStub, sinon.match.any, []);
+  });
+
+  it('pressing space creates new chips', async () => {
+    const onChangeStub = sinon.stub();
+
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute owl']}
+      multiple={true}
+      onChange={onChangeStub}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    sinon.assert.notCalled(onChangeStub);
+
+    userEvent.type(input, 'foobar');
+    sinon.assert.notCalled(onChangeStub);
+
+    fireEvent.keyDown(input, {key: ' ', code: 'Space'});
+    sinon.assert.calledOnce(onChangeStub);
+
+    assert.deepEqual(onChangeStub.getCall(0).args[1], ['foobar']);
+  });
+
+  it('_renderOption shows user input', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute@owl.com']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'ow');
+
+    const options = document.querySelectorAll('.autocomplete-option');
+
+    // Options: cute@owl.com
+    assert.deepEqual(options.length, 1);
+    assert.equal(options[0].textContent, 'cute@owl.com');
+
+    cleanAutocomplete(input);
+  });
+
+  it('_renderOption hides duplicate user input', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute@owl.com']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'cute@owl.com');
+
+    const options = document.querySelectorAll('.autocomplete-option');
+
+    // Options: cute@owl.com
+    assert.equal(options.length, 1);
+
+    assert.equal(options[0].textContent, 'cute@owl.com');
+
+    cleanAutocomplete(input);
+  });
+
+  it('_renderOption highlights matching text', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      options={['cute@owl.com']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'ow');
+
+    const option = document.querySelector('.autocomplete-option');
+    const match = option?.querySelector('strong');
+
+    assert.isNotNull(match);
+    assert.equal(match?.innerText, 'ow');
+
+    // Description is not rendered.
+    assert.equal(option?.querySelectorAll('span').length, 1);
+    assert.equal(option?.querySelectorAll('strong').length, 1);
+
+    cleanAutocomplete(input);
+  });
+
+  it('_renderOption highlights matching description', async () => {
+    const {container} = render(<ReactAutocomplete
+      label="cool"
+      getOptionDescription={() => 'penguin of-doom'}
+      options={['cute owl']}
+    />);
+
+    const input = container.querySelector('input');
+    assert.isNotNull(input);
+    if (!input) return;
+
+    userEvent.type(input, 'do');
+
+    const option = document.querySelector('.autocomplete-option');
+    const match = option?.querySelector('strong');
+
+    assert.isNotNull(match);
+    assert.equal(match?.innerText, 'do');
+
+    assert.equal(option?.querySelectorAll('span').length, 2);
+    assert.equal(option?.querySelectorAll('strong').length, 1);
+
+    cleanAutocomplete(input);
+  });
+
+  it('_renderTags disables fixedValues', async () => {
+    // TODO(crbug.com/monorail/9393): Add this test once we have a way to stub
+    // out dependent components.
+  });
+});
diff --git a/static_src/react/ReactAutocomplete.tsx b/static_src/react/ReactAutocomplete.tsx
new file mode 100644
index 0000000..27fdc32
--- /dev/null
+++ b/static_src/react/ReactAutocomplete.tsx
@@ -0,0 +1,276 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+
+import {FilterOptionsState} from '@material-ui/core';
+import Autocomplete, {
+  AutocompleteChangeDetails, AutocompleteChangeReason,
+  AutocompleteRenderGetTagProps, AutocompleteRenderInputParams,
+  AutocompleteRenderOptionState,
+} from '@material-ui/core/Autocomplete';
+import Chip, {ChipProps} from '@material-ui/core/Chip';
+import TextField from '@material-ui/core/TextField';
+import {Value} from '@material-ui/core/useAutocomplete';
+
+export const MAX_AUTOCOMPLETE_OPTIONS = 100;
+
+interface AutocompleteProps<T> {
+  label: string;
+  options: T[];
+  value?: Value<T, boolean, false, true>;
+  fixedValues?: T[];
+  inputType?: React.InputHTMLAttributes<unknown>['type'];
+  multiple?: boolean;
+  placeholder?: string;
+  onChange?: (
+    event: React.SyntheticEvent,
+    value: Value<T, boolean, false, true>,
+    reason: AutocompleteChangeReason,
+    details?: AutocompleteChangeDetails<T>
+  ) => void;
+  getOptionDescription?: (option: T) => string;
+  getOptionLabel?: (option: T) => string;
+}
+
+/**
+ * A wrapper around Material UI Autocomplete that customizes and extends it for
+ * Monorail's theme and options. Adds support for:
+ * - Fixed values that render as disabled chips.
+ * - Option descriptions that render alongside the option labels.
+ * - Matching on word boundaries in both the labels and descriptions.
+ * - Highlighting of the matching substrings.
+ * @return Autocomplete instance with Monorail-specific properties set.
+ */
+export function ReactAutocomplete<T>(
+  {
+    label, options, value = undefined, fixedValues = [], inputType = 'text',
+    multiple = false, placeholder = '', onChange = () => {},
+    getOptionDescription = () => '', getOptionLabel = (o) => String(o)
+  }: AutocompleteProps<T>
+): React.ReactNode {
+  value = value || (multiple ? [] : '');
+
+  return <Autocomplete
+    id={label}
+    autoHighlight
+    autoSelect
+    filterOptions={_filterOptions(getOptionDescription)}
+    filterSelectedOptions={multiple}
+    freeSolo
+    getOptionLabel={getOptionLabel}
+    multiple={multiple}
+    onChange={_onChange(fixedValues, multiple, onChange)}
+    onKeyDown={_onKeyDown}
+    options={options}
+    renderInput={_renderInput(inputType, placeholder)}
+    renderOption={_renderOption(getOptionDescription, getOptionLabel)}
+    renderTags={_renderTags(fixedValues, getOptionLabel)}
+    style={{width: 'var(--mr-edit-field-width)'}}
+    value={multiple ? [...fixedValues, ...value] : value}
+  />;
+}
+
+/**
+ * Modifies the default option matching behavior to match on all Regex word
+ * boundaries and to match on both label and description.
+ * @param getOptionDescription Function to get the description for an option.
+ * @return The text for a given option.
+ */
+function _filterOptions<T>(getOptionDescription: (option: T) => string) {
+  return (
+    options: T[],
+    {inputValue, getOptionLabel}: FilterOptionsState<T>
+  ): T[] => {
+    if (!inputValue.length) {
+      return [];
+    }
+    const regex = _matchRegex(inputValue);
+    const predicate = (option: T) => {
+      return getOptionLabel(option).match(regex) ||
+        getOptionDescription(option).match(regex);
+    }
+    return options.filter(predicate).slice(0, MAX_AUTOCOMPLETE_OPTIONS);
+  }
+}
+
+/**
+ * Computes an onChange handler for Autocomplete. Adds logic to make sure
+ * fixedValues are preserved and wraps whatever onChange handler the parent
+ * passed in.
+ * @param fixedValues Values that display in the edit field but can't be
+ *   edited by the user. Usually set by filter rules in Monorail.
+ * @param multiple Whether this input takes multiple values or not.
+ * @param onChange onChange property passed in by parent, used to sync value
+ *   changes to parent.
+ * @return Function that's run on Autocomplete changes.
+ */
+function _onChange<T, Multiple, DisableClearable, FreeSolo>(
+  fixedValues: T[],
+  multiple: Multiple,
+  onChange: (
+    event: React.SyntheticEvent,
+    value: Value<T, Multiple, DisableClearable, FreeSolo>,
+    reason: AutocompleteChangeReason,
+    details?: AutocompleteChangeDetails<T>
+  ) => void,
+) {
+  return (
+    event: React.SyntheticEvent,
+    newValue: Value<T, Multiple, DisableClearable, FreeSolo>,
+    reason: AutocompleteChangeReason,
+    details?: AutocompleteChangeDetails<T>
+  ): void => {
+    // Ensure that fixed values can't be removed.
+    if (multiple) {
+      newValue = newValue.filter((option: T) => !fixedValues.includes(option));
+    }
+
+    // Propagate onChange callback.
+    onChange(event, newValue, reason, details);
+  }
+}
+
+/**
+ * Custom keydown handler.
+ * @param e Keyboard event.
+ */
+function _onKeyDown(e: React.KeyboardEvent) {
+  // Convert spaces to Enter events to allow users to type space to create new
+  // chips.
+  if (e.key === ' ') {
+    e.key = 'Enter';
+  }
+}
+
+/**
+ * @param inputType A valid HTML 5 input type for the `input` element.
+ * @param placeholder Placeholder text for the input.
+ * @return A function that renders the input element used by
+ *   ReactAutocomplete.
+ */
+function _renderInput(inputType = 'text', placeholder = ''):
+    (params: AutocompleteRenderInputParams) => React.ReactNode {
+  return (params: AutocompleteRenderInputParams): React.ReactNode =>
+    <TextField
+      {...params} variant="standard" size="small"
+      type={inputType} placeholder={placeholder}
+    />;
+}
+
+/**
+ * Renders a single instance of an option for Autocomplete.
+ * @param getOptionDescription Function to get the description text shown.
+ * @param getOptionLabel Function to get the name of the option shown to the
+ *   user.
+ * @return ReactNode containing the JSX to be rendered.
+ */
+function _renderOption<T>(
+  getOptionDescription: (option: T) => string,
+  getOptionLabel: (option: T) => string
+): React.ReactNode {
+  return (
+    props: React.HTMLAttributes<HTMLLIElement>,
+    option: T,
+    {inputValue}: AutocompleteRenderOptionState
+  ): React.ReactNode => {
+    // Render the option label.
+    const label = getOptionLabel(option);
+    const matchValue = label.match(_matchRegex(inputValue));
+    let optionTemplate = <>{label}</>;
+    if (matchValue) {
+      // Highlight the matching text.
+      optionTemplate = <>
+        {matchValue[1]}
+        <strong>{matchValue[2]}</strong>
+        {matchValue[3]}
+      </>;
+    }
+
+    // Render the option description.
+    const description = getOptionDescription(option);
+    const matchDescription =
+      description && description.match(_matchRegex(inputValue));
+    let descriptionTemplate = <>{description}</>;
+    if (matchDescription) {
+      // Highlight the matching text.
+      descriptionTemplate = <>
+        {matchDescription[1]}
+        <strong>{matchDescription[2]}</strong>
+        {matchDescription[3]}
+      </>;
+    }
+
+    // Put the label and description together into one <li>.
+    return <li
+      {...props}
+      className={`${props.className} autocomplete-option`}
+      style={{display: 'flex', flexDirection: 'row', wordWrap: 'break-word'}}
+    >
+      <span style={{display: 'block', width: (description ? '40%' : '100%')}}>
+        {optionTemplate}
+      </span>
+      {description &&
+        <span style={{display: 'block', boxSizing: 'border-box',
+            paddingLeft: '8px', width: '60%'}}>
+          {descriptionTemplate}
+        </span>
+      }
+    </li>;
+  };
+}
+
+/**
+ * Helper to render the Chips elements used by Autocomplete. Ensures that
+ * fixedValues are disabled.
+ * @param fixedValues Undeleteable values in an issue usually set by filter
+ *   rules.
+ * @param getOptionLabel Function to compute text for the option.
+ * @return Function to render the ReactNode for all the chips.
+ */
+function _renderTags<T>(
+  fixedValues: T[], getOptionLabel: (option: T) => string
+) {
+  return (
+    value: T[],
+    getTagProps: AutocompleteRenderGetTagProps
+  ): React.ReactNode => {
+    return value.map((option, index) => {
+      const props: ChipProps = {...getTagProps({index})};
+      const disabled = fixedValues.includes(option);
+      if (disabled) {
+        delete props.onDelete;
+      }
+
+      const label = getOptionLabel(option);
+      return <Chip
+        {...props}
+        key={label}
+        label={label}
+        disabled={disabled}
+        size="small"
+      />;
+    });
+  }
+}
+
+/**
+ * Generates a RegExp to match autocomplete values.
+ * @param needle The string the user is searching for.
+ * @return A RegExp to find matching values.
+ */
+function _matchRegex(needle: string): RegExp {
+  // This code copied from ac.js.
+  // Since we use needle to build a regular expression, we need to escape RE
+  // characters. We match '-', '{', '$' and others in the needle and convert
+  // them into "\-", "\{", "\$".
+  const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
+  const modifiedPrefix = needle.replace(regexForRegexCharacters, '\\$1');
+
+  // Match the modifiedPrefix anywhere as long as it is either at the very
+  // beginning "Th" -> "The Hobbit", or comes immediately after a word separator
+  // such as "Ga" -> "The-Great-Gatsby".
+  const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
+  return new RegExp(patternRegex, 'i' /* ignore case */);
+}
diff --git a/static_src/react/issue-wizard/DetailsStep.test.tsx b/static_src/react/issue-wizard/DetailsStep.test.tsx
new file mode 100644
index 0000000..eaef0e7
--- /dev/null
+++ b/static_src/react/issue-wizard/DetailsStep.test.tsx
@@ -0,0 +1,34 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {render, cleanup} from '@testing-library/react';
+import {assert} from 'chai';
+
+import DetailsStep from './DetailsStep.tsx';
+
+describe('DetailsStep', () => {
+  afterEach(cleanup);
+
+  it('renders', async () => {
+    const {container} = render(<DetailsStep />);
+
+    // this is checking for the first question
+    const input = container.querySelector('input');
+    assert.isNotNull(input)
+
+    // this is checking for the rest
+    const count = document.querySelectorAll('textarea').length;
+    assert.equal(count, 3)
+  });
+
+  it('renders category in title', async () => {
+    const {container} = render(<DetailsStep category='UI'/>);
+
+    // this is checking the title contains our category
+    const title = container.querySelector('h2');
+    assert.include(title?.innerText, 'Details for problems with UI');
+  });
+
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/DetailsStep.tsx b/static_src/react/issue-wizard/DetailsStep.tsx
new file mode 100644
index 0000000..1a69cc1
--- /dev/null
+++ b/static_src/react/issue-wizard/DetailsStep.tsx
@@ -0,0 +1,65 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {createStyles, createTheme} from '@material-ui/core/styles';
+import {makeStyles} from '@material-ui/styles';
+import TextField from '@material-ui/core/TextField';
+import {red, grey} from '@material-ui/core/colors';
+
+/**
+ * The detail step is the second step on the dot
+ * stepper. This react component provides the users with
+ * specific questions about their bug to be filled out.
+ */
+const theme: Theme = createTheme();
+
+const useStyles = makeStyles((theme: Theme) =>
+  createStyles({
+    root: {
+      '& > *': {
+        margin: theme.spacing(1),
+        width: '100%',
+      },
+    },
+    head: {
+        marginTop: '25px',
+    },
+    red: {
+        color: red[600],
+    },
+    grey: {
+        color: grey[600],
+    },
+  }), {defaultTheme: theme}
+);
+
+export default function DetailsStep({textValues, setTextValues, category}:
+  {textValues: Object, setTextValues: Function, category: string}): React.ReactElement {
+  const classes = useStyles();
+
+  const handleChange = (valueName: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
+    const textInput = e.target.value;
+    setTextValues({...textValues, [valueName]: textInput});
+  };
+
+  return (
+    <>
+        <h2 className={classes.grey}>Details for problems with {category}</h2>
+        <form className={classes.root} noValidate autoComplete="off">
+            <h3 className={classes.head}>Please enter a one line summary <span className={classes.red}>*</span></h3>
+            <TextField id="outlined-basic-1" variant="outlined" onChange={handleChange('oneLineSummary')}/>
+
+            <h3 className={classes.head}>Steps to reproduce problem <span className={classes.red}>*</span></h3>
+            <TextField multiline rows={4} id="outlined-basic-2" variant="outlined" onChange={handleChange('stepsToReproduce')}/>
+
+            <h3 className={classes.head}>Please describe the problem <span className={classes.red}>*</span></h3>
+            <TextField multiline rows={3} id="outlined-basic-3" variant="outlined" onChange={handleChange('describeProblem')}/>
+
+            <h3 className={classes.head}>Additional Comments</h3>
+            <TextField multiline rows={3} id="outlined-basic-4" variant="outlined" onChange={handleChange('additionalComments')}/>
+        </form>
+    </>
+  );
+}
diff --git a/static_src/react/issue-wizard/DotMobileStepper.test.tsx b/static_src/react/issue-wizard/DotMobileStepper.test.tsx
new file mode 100644
index 0000000..5203110
--- /dev/null
+++ b/static_src/react/issue-wizard/DotMobileStepper.test.tsx
@@ -0,0 +1,59 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {render, screen, cleanup} from '@testing-library/react';
+import {assert} from 'chai';
+
+import DotMobileStepper from './DotMobileStepper.tsx';
+
+describe('DotMobileStepper', () => {
+  let container: HTMLElement;
+
+  afterEach(cleanup);
+
+  it('renders', () => {
+    container = render(<DotMobileStepper activeStep={0} nextEnabled={true}/>).container;
+
+    // this is checking the buttons for the stepper rendered
+      const count = document.querySelectorAll('button').length;
+      assert.equal(count, 2)
+  });
+
+  it('back button disabled on first step', () => {
+    render(<DotMobileStepper activeStep={0} nextEnabled={true}/>).container;
+
+    // Finds a button on the page with "back" as text using React testing library.
+    const backButton = screen.getByRole('button', {name: /backButton/i}) as HTMLButtonElement;
+
+    // Back button is disabled on the first step.
+    assert.isTrue(backButton.disabled);
+  });
+
+  it('both buttons enabled on second step', () => {
+    render(<DotMobileStepper activeStep={1} nextEnabled={true}/>).container;
+
+    // Finds a button on the page with "back" as text using React testing library.
+    const backButton = screen.getByRole('button', {name: /backButton/i}) as HTMLButtonElement;
+
+    // Finds a button on the page with "next" as text using React testing library.
+    const nextButton = screen.getByRole('button', {name: /nextButton/i}) as HTMLButtonElement;
+
+    // Back button is not disabled on the second step.
+    assert.isFalse(backButton.disabled);
+
+    // Next button is not disabled on the second step.
+    assert.isFalse(nextButton.disabled);
+  });
+
+  it('next button disabled on last step', () => {
+    render(<DotMobileStepper activeStep={2}/>).container;
+
+    // Finds a button on the page with "next" as text using React testing library.
+    const nextButton = screen.getByRole('button', {name: /nextButton/i}) as HTMLButtonElement;
+
+    // Next button is disabled on the second step.
+    assert.isTrue(nextButton.disabled);
+  });
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/DotMobileStepper.tsx b/static_src/react/issue-wizard/DotMobileStepper.tsx
new file mode 100644
index 0000000..9870f03
--- /dev/null
+++ b/static_src/react/issue-wizard/DotMobileStepper.tsx
@@ -0,0 +1,72 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {createTheme} from '@material-ui/core/styles';
+import {makeStyles} from '@material-ui/styles';
+import MobileStepper from '@material-ui/core/MobileStepper';
+import Button from '@material-ui/core/Button';
+import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
+import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
+
+const theme: Theme = createTheme();
+
+const useStyles = makeStyles({
+  root: {
+    width: '100%',
+    flexGrow: 1,
+  },
+}, {defaultTheme: theme});
+
+/**
+ * `<DotMobileStepper />`
+ *
+ * React component for rendering the linear dot stepper of the issue wizard.
+ *
+ *  @return ReactElement.
+ */
+export default function DotsMobileStepper({nextEnabled, activeStep, setActiveStep} : {nextEnabled: boolean, activeStep: number, setActiveStep: Function}) : React.ReactElement {
+  const classes = useStyles();
+
+  const handleNext = () => {
+    setActiveStep((prevActiveStep: number) => prevActiveStep + 1);
+  };
+
+  const handleBack = () => {
+    setActiveStep((prevActiveStep: number) => prevActiveStep - 1);
+  };
+
+  let label;
+  let icon;
+
+  if (activeStep === 2){
+    label = 'Submit';
+    icon = '';
+  } else {
+    label = 'Next';
+    icon = <KeyboardArrowRight />;
+  }
+  return (
+    <MobileStepper
+      id="mobile-stepper"
+      variant="dots"
+      steps={3}
+      position="static"
+      activeStep={activeStep}
+      className={classes.root}
+      nextButton={
+        <Button aria-label="nextButton" size="medium" onClick={handleNext} disabled={activeStep === 2 || !nextEnabled}>
+          {label}
+          {icon}
+        </Button>
+      }
+      backButton={
+        <Button aria-label="backButton" size="medium" onClick={handleBack} disabled={activeStep === 0}>
+          <KeyboardArrowLeft />
+          Back
+        </Button>
+      }
+    />
+  );
+}
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/LandingStep.tsx b/static_src/react/issue-wizard/LandingStep.tsx
new file mode 100644
index 0000000..efe6491
--- /dev/null
+++ b/static_src/react/issue-wizard/LandingStep.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import {makeStyles, withStyles} from '@material-ui/styles';
+import {blue, yellow, red, grey} from '@material-ui/core/colors';
+import FormControlLabel from '@material-ui/core/FormControlLabel';
+import Checkbox, {CheckboxProps} from '@material-ui/core/Checkbox';
+import SelectMenu from './SelectMenu.tsx';
+import RadioDescription from './RadioDescription.tsx';
+
+const CustomCheckbox = withStyles({
+  root: {
+    color: blue[400],
+    '&$checked': {
+      color: blue[600],
+    },
+  },
+  checked: {},
+})((props: CheckboxProps) => <Checkbox color="default" {...props} />);
+
+const useStyles = makeStyles({
+  pad: {
+    margin: '10px, 20px',
+    display: 'inline-block',
+  },
+  flex: {
+    display: 'flex',
+  },
+  inlineBlock: {
+    display: 'inline-block',
+  },
+  warningBox: {
+    minHeight: '10vh',
+    borderStyle: 'solid',
+    borderWidth: '2px',
+    borderColor: yellow[800],
+    borderRadius: '8px',
+    background: yellow[50],
+    padding: '0px 20px 1em',
+    margin: '30px 0px'
+  },
+  warningHeader: {
+    color: yellow[800],
+    fontSize: '16px',
+    fontWeight: '500',
+  },
+  star:{
+    color: red[700],
+    marginRight: '8px',
+    fontSize: '16px',
+    display: 'inline-block',
+  },
+  header: {
+    color: grey[900],
+    fontSize: '28px',
+    marginTop: '6vh',
+  },
+  subheader: {
+    color: grey[700],
+    fontSize: '18px',
+    lineHeight: '32px',
+  },
+  red: {
+    color: red[600],
+  },
+});
+
+export default function LandingStep({checkExisting, setCheckExisting, userType, setUserType, category, setCategory}:
+  {checkExisting: boolean, setCheckExisting: Function, userType: string, setUserType: Function, category: string, setCategory: Function}) {
+  const classes = useStyles();
+
+  const handleCheckChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setCheckExisting(event.target.checked);
+  };
+
+  return (
+    <>
+      <p className={classes.header}>Report an issue with Chromium</p>
+      <p className={classes.subheader}>
+        We want you to enter the best possible issue report so that the project team members
+        can act on it effectively. The following steps will help route your issue to the correct
+        people.
+      </p>
+      <p className={classes.subheader}>
+        Please select your following role: <span className={classes.red}>*</span>
+      </p>
+      <RadioDescription value={userType} setValue={setUserType}/>
+      <div className={classes.subheader}>
+        Which of the following best describes the issue that you are reporting? <span className={classes.red}>*</span>
+      </div>
+      <SelectMenu option={category} setOption={setCategory}/>
+      <div className={classes.warningBox}>
+        <p className={classes.warningHeader}> Avoid duplicate issue reports:</p>
+        <div>
+          <div className={classes.star}>*</div>
+          <FormControlLabel className={classes.pad}
+            control={
+              <CustomCheckbox
+                checked={checkExisting}
+                onChange={handleCheckChange}
+                name="warningCheck"
+              />
+            }
+            label="By checking this box, I'm acknowledging that I have searched for existing issues that already report this problem."
+          />
+        </div>
+      </div>
+    </>
+  );
+}
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/RadioDescription.test.tsx b/static_src/react/issue-wizard/RadioDescription.test.tsx
new file mode 100644
index 0000000..ff65eae
--- /dev/null
+++ b/static_src/react/issue-wizard/RadioDescription.test.tsx
@@ -0,0 +1,54 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {render, screen, cleanup} from '@testing-library/react';
+import userEvent from '@testing-library/user-event'
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import RadioDescription from './RadioDescription.tsx';
+
+describe('RadioDescription', () => {
+  afterEach(cleanup);
+
+  it('renders', () => {
+    render(<RadioDescription />);
+    // look for blue radios
+      const radioOne = screen.getByRole('radio', {name: /Web Developer/i});
+      assert.isNotNull(radioOne)
+
+      const radioTwo = screen.getByRole('radio', {name: /End User/i});
+      assert.isNotNull(radioTwo)
+
+      const radioThree = screen.getByRole('radio', {name: /Chromium Contributor/i});
+      assert.isNotNull(radioThree)
+  });
+
+  it('checks selected radio value', () => {
+    // We're passing in the "Web Developer" value here manually
+    // to tell our code that that radio button is selected.
+    render(<RadioDescription value={'Web Developer'} />);
+
+    const checkedRadio = screen.getByRole('radio', {name: /Web Developer/i});
+    assert.isTrue(checkedRadio.checked);
+
+    // Extra check to make sure we haven't checked every single radio button.
+    const uncheckedRadio = screen.getByRole('radio', {name: /End User/i});
+    assert.isFalse(uncheckedRadio.checked);
+  });
+
+  it('sets radio value when clicked', () => {
+    // Using the sinon.js testing library to create a function for testing.
+    const setValue = sinon.stub();
+
+    render(<RadioDescription setValue={setValue} />);
+
+    const radio = screen.getByRole('radio', {name: /Web Developer/i});
+    userEvent.click(radio);
+
+    // Asserts that "Web Developer" was passed into our "setValue" function.
+    sinon.assert.calledWith(setValue, 'Web Developer');
+  });
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/RadioDescription.tsx b/static_src/react/issue-wizard/RadioDescription.tsx
new file mode 100644
index 0000000..ad78c78
--- /dev/null
+++ b/static_src/react/issue-wizard/RadioDescription.tsx
@@ -0,0 +1,117 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {makeStyles, withStyles} from '@material-ui/styles';
+import {blue, grey} from '@material-ui/core/colors';
+import Radio, {RadioProps} from '@material-ui/core/Radio';
+
+const userGroups = Object.freeze({
+  END_USER: 'End User',
+  WEB_DEVELOPER: 'Web Developer',
+  CONTRIBUTOR: 'Chromium Contributor',
+});
+
+const BlueRadio = withStyles({
+  root: {
+    color: blue[400],
+    '&$checked': {
+      color: blue[600],
+    },
+  },
+  checked: {},
+})((props: RadioProps) => <Radio color="default" {...props} />);
+
+const useStyles = makeStyles({
+  flex: {
+    display: 'flex',
+    justifyContent: 'space-between',
+  },
+  container: {
+    width: '320px',
+    height: '150px',
+    position: 'relative',
+    display: 'inline-block',
+  },
+  text: {
+    position: 'absolute',
+    display: 'inline-block',
+    left: '55px',
+  },
+  title: {
+    marginTop: '7px',
+    fontSize: '20px',
+    color: grey[900],
+  },
+  subheader: {
+    fontSize: '16px',
+    color: grey[800],
+  },
+  line: {
+    position: 'absolute',
+    bottom: 0,
+    width: '300px',
+    left: '20px',
+  }
+});
+
+/**
+ * `<RadioDescription />`
+ *
+ * React component for radio buttons and their descriptions
+ * on the landing step of the Issue Wizard.
+ *
+ *  @return ReactElement.
+ */
+export default function RadioDescription({value, setValue} : {value: string, setValue: Function}): React.ReactElement {
+  const classes = useStyles();
+
+  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setValue(event.target.value);
+  };
+
+  return (
+    <div className={classes.flex}>
+      <div className={classes.container}>
+        <BlueRadio
+          checked={value === userGroups.END_USER}
+          onChange={handleChange}
+          value={userGroups.END_USER}
+          inputProps={{ 'aria-label': userGroups.END_USER}}
+        />
+        <div className={classes.text}>
+          <p className={classes.title}>{userGroups.END_USER}</p>
+          <p className={classes.subheader}>I am a user trying to do something on a website.</p>
+        </div>
+        <hr color={grey[200]} className={classes.line}/>
+      </div>
+      <div className={classes.container}>
+        <BlueRadio
+          checked={value === userGroups.WEB_DEVELOPER}
+          onChange={handleChange}
+          value={userGroups.WEB_DEVELOPER}
+          inputProps={{ 'aria-label': userGroups.WEB_DEVELOPER }}
+        />
+        <div className={classes.text}>
+          <p className={classes.title}>{userGroups.WEB_DEVELOPER}</p>
+          <p className={classes.subheader}>I am a web developer trying to build something.</p>
+        </div>
+        <hr color={grey[200]} className={classes.line}/>
+      </div>
+      <div className={classes.container}>
+        <BlueRadio
+          checked={value === userGroups.CONTRIBUTOR}
+          onChange={handleChange}
+          value={userGroups.CONTRIBUTOR}
+          inputProps={{ 'aria-label': userGroups.CONTRIBUTOR }}
+        />
+        <div className={classes.text}>
+          <p className={classes.title}>{userGroups.CONTRIBUTOR}</p>
+          <p className={classes.subheader}>I know about a problem in specific tests or code.</p>
+        </div>
+        <hr color={grey[200]} className={classes.line}/>
+      </div>
+    </div>
+    );
+  }
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/SelectMenu.test.tsx b/static_src/react/issue-wizard/SelectMenu.test.tsx
new file mode 100644
index 0000000..13efef6
--- /dev/null
+++ b/static_src/react/issue-wizard/SelectMenu.test.tsx
@@ -0,0 +1,38 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {render} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {screen} from '@testing-library/dom';
+import {assert} from 'chai';
+
+import SelectMenu from './SelectMenu.tsx';
+
+describe('SelectMenu', () => {
+  let container: React.RenderResult;
+
+  beforeEach(() => {
+    container = render(<SelectMenu />).container;
+  });
+
+  it('renders', () => {
+    const form = container.querySelector('form');
+    assert.isNotNull(form)
+  });
+
+  it('renders options on click', () => {
+    const input = document.getElementById('outlined-select-category');
+    if (!input) {
+      throw new Error('Input is undefined');
+    }
+
+    userEvent.click(input)
+
+    // 14 is the current number of options in the select menu
+    const count = screen.getAllByTestId('select-menu-item').length;
+
+    assert.equal(count, 14);
+  });
+});
\ No newline at end of file
diff --git a/static_src/react/issue-wizard/SelectMenu.tsx b/static_src/react/issue-wizard/SelectMenu.tsx
new file mode 100644
index 0000000..3b0b96d
--- /dev/null
+++ b/static_src/react/issue-wizard/SelectMenu.tsx
@@ -0,0 +1,133 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import React from 'react';
+import {createTheme, Theme} from '@material-ui/core/styles';
+import {makeStyles} from '@material-ui/styles';
+import MenuItem from '@material-ui/core/MenuItem';
+import TextField from '@material-ui/core/TextField';
+
+const CATEGORIES = [
+  {
+    value: 'UI',
+    label: 'UI',
+  },
+  {
+    value: 'Accessibility',
+    label: 'Accessibility',
+  },
+  {
+    value: 'Network/Downloading',
+    label: 'Network/Downloading',
+  },
+  {
+    value: 'Audio/Video',
+    label: 'Audio/Video',
+  },
+  {
+    value: 'Content',
+    label: 'Content',
+  },
+  {
+    value: 'Apps',
+    label: 'Apps',
+  },
+  {
+    value: 'Extensions/Themes',
+    label: 'Extensions/Themes',
+  },
+  {
+    value: 'Webstore',
+    label: 'Webstore',
+  },
+  {
+    value: 'Sync',
+    label: 'Sync',
+  },
+  {
+    value: 'Enterprise',
+    label: 'Enterprise',
+  },
+  {
+    value: 'Installation',
+    label: 'Installation',
+  },
+  {
+    value: 'Crashes',
+    label: 'Crashes',
+  },
+  {
+    value: 'Security',
+    label: 'Security',
+  },
+  {
+    value: 'Other',
+    label: 'Other',
+  },
+];
+
+const theme: Theme = createTheme();
+
+const useStyles = makeStyles((theme: Theme) => ({
+  container: {
+    display: 'flex',
+    flexWrap: 'wrap',
+    maxWidth: '65%',
+  },
+  textField: {
+    marginLeft: theme.spacing(1),
+    marginRight: theme.spacing(1),
+  },
+  menu: {
+    width: '100%',
+    minWidth: '300px',
+  },
+}), {defaultTheme: theme});
+
+/**
+ * Select menu component that is located on the landing step if the
+ * Issue Wizard. The menu is used for the user to indicate the category
+ * of their bug when filing an issue.
+ *
+ * @return ReactElement.
+ */
+export default function SelectMenu({option, setOption}: {option: string, setOption: Function}) {
+  const classes = useStyles();
+  const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
+    setOption(event.target.value as string);
+  };
+
+  return (
+    <form className={classes.container} noValidate autoComplete="off">
+      <TextField
+        id="outlined-select-category"
+        select
+        label=''
+        className={classes.textField}
+        value={option}
+        onChange={handleChange}
+        InputLabelProps={{shrink: false}}
+        SelectProps={{
+          MenuProps: {
+            className: classes.menu,
+          },
+        }}
+        margin="normal"
+        variant="outlined"
+        fullWidth={true}
+      >
+      {CATEGORIES.map(option => (
+        <MenuItem
+          className={classes.menu}
+          key={option.value}
+          value={option.value}
+          data-testid="select-menu-item"
+        >
+           {option.label}
+        </MenuItem>
+       ))}
+      </TextField>
+    </form>
+  );
+}
\ No newline at end of file
diff --git a/static_src/react/mr-react-autocomplete.test.ts b/static_src/react/mr-react-autocomplete.test.ts
new file mode 100644
index 0000000..8553c36
--- /dev/null
+++ b/static_src/react/mr-react-autocomplete.test.ts
@@ -0,0 +1,158 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import {MrReactAutocomplete} from './mr-react-autocomplete.tsx';
+
+let element: MrReactAutocomplete;
+
+describe('mr-react-autocomplete', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-react-autocomplete');
+    element.vocabularyName = 'member';
+    document.body.appendChild(element);
+
+    sinon.stub(element, 'stateChanged');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrReactAutocomplete);
+  });
+
+  it('ReactDOM renders on update', async () => {
+    element.value = 'Penguin Island';
+
+    await element.updateComplete;
+
+    const input = element.querySelector('input');
+
+    assert.equal(input?.value, 'Penguin Island');
+  });
+
+  it('does not update on new copies of the same values', async () => {
+    element.fixedValues = ['test'];
+    element.value = ['hah'];
+
+    sinon.spy(element, 'updated');
+
+    await element.updateComplete;
+    sinon.assert.calledOnce(element.updated);
+
+    element.fixedValues = ['test'];
+    element.value = ['hah'];
+
+    await element.updateComplete;
+    sinon.assert.calledOnce(element.updated);
+  });
+
+  it('_getOptionDescription with component vocabulary gets docstring', () => {
+    element.vocabularyName = 'component';
+    element._components = new Map([['Infra>UI', {docstring: 'Test docs'}]]);
+    element._labels = new Map([['M-84', {docstring: 'Test label docs'}]]);
+
+    assert.equal(element._getOptionDescription('Infra>UI'), 'Test docs');
+    assert.equal(element._getOptionDescription('M-84'), '');
+    assert.equal(element._getOptionDescription('NoMatch'), '');
+  });
+
+  it('_getOptionDescription with label vocabulary gets docstring', () => {
+    element.vocabularyName = 'label';
+    element._components = new Map([['Infra>UI', {docstring: 'Test docs'}]]);
+    element._labels = new Map([['m-84', {docstring: 'Test label docs'}]]);
+
+    assert.equal(element._getOptionDescription('Infra>UI'), '');
+    assert.equal(element._getOptionDescription('M-84'), 'Test label docs');
+    assert.equal(element._getOptionDescription('NoMatch'), '');
+  });
+
+  it('_getOptionDescription with other vocabulary gets empty docstring', () => {
+    element.vocabularyName = 'owner';
+    element._components = new Map([['Infra>UI', {docstring: 'Test docs'}]]);
+    element._labels = new Map([['M-84', {docstring: 'Test label docs'}]]);
+
+    assert.equal(element._getOptionDescription('Infra>UI'), '');
+    assert.equal(element._getOptionDescription('M-84'), '');
+    assert.equal(element._getOptionDescription('NoMatch'), '');
+  });
+
+  it('_options gets component names', () => {
+    element.vocabularyName = 'component';
+    element._components = new Map([
+      ['Infra>UI', {docstring: 'Test docs'}],
+      ['Bird>Penguin', {docstring: 'Test docs'}],
+    ]);
+
+    assert.deepEqual(element._options(), ['Infra>UI', 'Bird>Penguin']);
+  });
+
+  it('_options gets label names', () => {
+    element.vocabularyName = 'label';
+    element._labels = new Map([
+      ['M-84', {label: 'm-84', docstring: 'Test docs'}],
+      ['Restrict-View-Bagel', {label: 'restrict-VieW-bAgEl', docstring: 'T'}],
+    ]);
+
+    assert.deepEqual(element._options(), ['m-84', 'restrict-VieW-bAgEl']);
+  });
+
+  it('_options gets member names with groups', () => {
+    element.vocabularyName = 'member';
+    element._members = {
+      userRefs: [
+        {displayName: 'penguin@island.com'},
+        {displayName: 'google@monorail.com'},
+        {displayName: 'group@birds.com'},
+      ],
+      groupRefs: [{displayName: 'group@birds.com'}],
+    };
+
+    assert.deepEqual(element._options(),
+        ['penguin@island.com', 'google@monorail.com', 'group@birds.com']);
+  });
+
+  it('_options gets owner names without groups', () => {
+    element.vocabularyName = 'owner';
+    element._members = {
+      userRefs: [
+        {displayName: 'penguin@island.com'},
+        {displayName: 'google@monorail.com'},
+        {displayName: 'group@birds.com'},
+      ],
+      groupRefs: [{displayName: 'group@birds.com'}],
+    };
+
+    assert.deepEqual(element._options(),
+        ['penguin@island.com', 'google@monorail.com']);
+  });
+
+  it('_options gets owner names without groups', () => {
+    element.vocabularyName = 'project';
+    element._projects = {
+      ownerOf: ['penguins'],
+      memberOf: ['birds'],
+      contributorTo: ['canary', 'owl-island'],
+    };
+
+    assert.deepEqual(element._options(),
+        ['penguins', 'birds', 'canary', 'owl-island']);
+  });
+
+  it('_options gives empty array for empty vocabulary name', () => {
+    element.vocabularyName = '';
+    assert.deepEqual(element._options(), []);
+  });
+
+  it('_options throws error on unknown vocabulary', () => {
+    element.vocabularyName = 'whatever';
+
+    assert.throws(element._options.bind(element),
+        'Unknown vocabulary name: whatever');
+  });
+});
diff --git a/static_src/react/mr-react-autocomplete.tsx b/static_src/react/mr-react-autocomplete.tsx
new file mode 100644
index 0000000..8cc5f84
--- /dev/null
+++ b/static_src/react/mr-react-autocomplete.tsx
@@ -0,0 +1,176 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, property, internalProperty} from 'lit-element';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import deepEqual from 'deep-equal';
+
+import {AutocompleteChangeDetails, AutocompleteChangeReason}
+  from '@material-ui/core/Autocomplete';
+import {ThemeProvider, createTheme} from '@material-ui/core/styles';
+
+import {connectStore} from 'reducers/base.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {userRefsToDisplayNames} from 'shared/convertersV0.js';
+import {arrayDifference} from 'shared/helpers.js';
+
+import {ReactAutocomplete} from 'react/ReactAutocomplete.tsx';
+
+type Vocabulary = 'component' | 'label' | 'member' | 'owner' | 'project' | '';
+
+
+/**
+ * A normal text input enhanced by a panel of suggested options.
+ * `<mr-react-autocomplete>` wraps a React implementation of autocomplete
+ * in a web component, suitable for embedding in a LitElement component
+ * hierarchy. All parents must not use Shadow DOM. The supported autocomplete
+ * option types are defined in type Vocabulary.
+ */
+export class MrReactAutocomplete extends connectStore(LitElement) {
+  // Required properties passed in from the parent element.
+  /** The `<input id>` attribute. Called "label" to avoid name conflicts. */
+  @property() label: string = '';
+  /** The autocomplete option type. See type Vocabulary for the full list. */
+  @property() vocabularyName: Vocabulary = '';
+
+  // Optional properties passed in from the parent element.
+  /** The value (or values, if `multiple === true`). */
+  @property({
+    hasChanged: (oldVal, newVal) => !deepEqual(oldVal, newVal),
+  }) value?: string | string[] = undefined;
+  /** Values that show up as disabled chips. */
+  @property({
+    hasChanged: (oldVal, newVal) => !deepEqual(oldVal, newVal),
+  }) fixedValues: string[] = [];
+  /** A valid HTML 5 input type for the `input` element. */
+  @property() inputType: string = 'text';
+  /** True for chip input that takes multiple values, false for single input. */
+  @property() multiple: boolean = false;
+  /** Placeholder for the form input. */
+  @property() placeholder?: string = '';
+  /** Callback for input value changes. */
+  @property() onChange: (
+    event: React.SyntheticEvent,
+    newValue: string | string[] | null,
+    reason: AutocompleteChangeReason,
+    details?: AutocompleteChangeDetails
+  ) => void = () => {};
+
+  // Internal state properties from the Redux store.
+  @internalProperty() protected _components:
+    Map<string, ComponentDef> = new Map();
+  @internalProperty() protected _labels: Map<string, LabelDef> = new Map();
+  @internalProperty() protected _members:
+    {userRefs?: UserRef[], groupRefs?: UserRef[]} = {};
+  @internalProperty() protected _projects:
+    {contributorTo?: string[], memberOf?: string[], ownerOf?: string[]} = {};
+
+  /** @override */
+  createRenderRoot(): LitElement {
+    return this;
+  }
+
+  /** @override */
+  updated(changedProperties: Map<string | number | symbol, unknown>): void {
+    super.updated(changedProperties);
+
+    const theme = createTheme({
+      components: {
+        MuiChip: {
+          styleOverrides: {
+            root: {fontSize: 13},
+          },
+        },
+      },
+      palette: {
+        action: {disabledOpacity: 0.6},
+        primary: {
+          // Same as var(--chops-primary-accent-color).
+          main: '#1976d2',
+        },
+      },
+      typography: {fontSize: 11.375},
+    });
+    const element = <ThemeProvider theme={theme}>
+      <ReactAutocomplete
+        label={this.label}
+        options={this._options()}
+        value={this.value}
+        fixedValues={this.fixedValues}
+        inputType={this.inputType}
+        multiple={this.multiple}
+        placeholder={this.placeholder}
+        onChange={this.onChange}
+        getOptionDescription={this._getOptionDescription.bind(this)}
+        getOptionLabel={(option: string) => option}
+      />
+    </ThemeProvider>;
+    ReactDOM.render(element, this);
+  }
+
+  /** @override */
+  stateChanged(state: any): void {
+    super.stateChanged(state);
+
+    this._components = projectV0.componentsMap(state);
+    this._labels = projectV0.labelDefMap(state);
+    this._members = projectV0.viewedVisibleMembers(state);
+    this._projects = userV0.projects(state);
+  }
+
+  /**
+   * Computes which description belongs to given autocomplete option.
+   * Different data is shown depending on the autocomplete vocabulary.
+   * @param option The option to find a description for.
+   * @return The description for the option.
+   */
+  _getOptionDescription(option: string): string {
+    switch (this.vocabularyName) {
+      case 'component': {
+        const component = this._components.get(option);
+        return component && component.docstring || '';
+      } case 'label': {
+        const label = this._labels.get(option.toLowerCase());
+        return label && label.docstring || '';
+      } default: {
+        return '';
+      }
+    }
+  }
+
+  /**
+   * Computes the set of options used by the autocomplete instance.
+   * @return Array of strings that the user can try to match.
+   */
+  _options(): string[] {
+    switch (this.vocabularyName) {
+      case 'component': {
+        return [...this._components.keys()];
+      } case 'label': {
+        // The label map keys are lowercase. Use the LabelDef label name instead.
+        return [...this._labels.values()].map((labelDef: LabelDef) => labelDef.label);
+      } case 'member': {
+        const {userRefs = []} = this._members;
+        const users = userRefsToDisplayNames(userRefs);
+        return users;
+      } case 'owner': {
+        const {userRefs = [], groupRefs = []} = this._members;
+        const users = userRefsToDisplayNames(userRefs);
+        const groups = userRefsToDisplayNames(groupRefs);
+        // Remove groups from the list of all members.
+        return arrayDifference(users, groups);
+      } case 'project': {
+        const {ownerOf = [], memberOf = [], contributorTo = []} = this._projects;
+        return [...ownerOf, ...memberOf, ...contributorTo];
+      } case '': {
+        return [];
+      } default: {
+        throw new Error(`Unknown vocabulary name: ${this.vocabularyName}`);
+      }
+    }
+  }
+}
+customElements.define('mr-react-autocomplete', MrReactAutocomplete);