Repository: equinor/design-system (ts)

Issue:
Add support for user-defined options in Autocomplete
**Is your feature request related to a problem? Please describe.**

We currently use the Autocomplete component for predefined select options. However, there is no way to allow users to input and select values that are not part of the predefined list. This becomes a limitation when a user needs to add their own value on the fly, similar to the [freeSolo](https://mui.com/material-ui/react-autocomplete/#free-solo) behaviour in Material-UI. 

**Describe the solution you’d like**

Autocomplete component should support a “free solo” mode where users can input custom values that are not part of the current options. Ideally, when the user types a new value and selects it (e.g. by pressing Enter), it should be treated as a valid selection and optionally added to the list.

**Describe alternatives you’ve considered**

Users could consider adding a separate text input field, but it breaks the unified experience of using a single component for both predefined and custom input. Another option would be maintaining a local state that merges new user input into the options array, but that adds significant complexity on the implementer’s side.

**Additional context**

The request originated from a user (Anand Prakash Shukla) via Slack, who asked for a feature similar to Material-UI’s freeSolo prop.

Pull request:
feat(eds-core-react): ✨ enhance Autocomplete onAddNewOption - stories, tests, and accessibility
Resolves #3794 

Adding to #3833 

Patch:
diff --git a/packages/eds-core-react/src/components/Autocomplete/AddNewOption.tsx b/packages/eds-core-react/src/components/Autocomplete/AddNewOption.tsx
index aab20fdb..1b39a846 100644
--- a/packages/eds-core-react/src/components/Autocomplete/AddNewOption.tsx
+++ b/packages/eds-core-react/src/components/Autocomplete/AddNewOption.tsx
@@ -30,6 +30,7 @@ function AddNewOptionInner(
     <StyledListItem
       ref={ref}
       $highlighted={highlighted}
+      aria-label={`Add new option: ${value}`}
       onClick={(e) => {
         onClick(e)
       }}
diff --git a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.stories.tsx b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.stories.tsx
index 78490a10..7be5e05c 100644
--- a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.stories.tsx
+++ b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.stories.tsx
@@ -1039,3 +1039,78 @@ export const LargeDatasets: StoryFn<AutocompleteProps<MyOptionType>> = (
 LargeDatasets.args = {
   options: stocks,
 }
+
+export const AddNewOption: StoryFn<AutocompleteProps<MyOptionType>> = () => {
+  const [options, setOptions] = useState<MyOptionType[]>([...stocks])
+  const [selectedItems, setSelectedItems] = useState<MyOptionType[]>([])
+
+  const handleOptionsChange = (changes: AutocompleteChanges<MyOptionType>) => {
+    setSelectedItems(changes.selectedItems)
+  }
+
+  const handleAddNewOption = (newOption: string) => {
+    // Create a new object with the input as company name
+    const newStock: MyOptionType = {
+      label: newOption,
+      symbol: newOption.substring(0, 4).toUpperCase(),
+      trend: '',
+    }
+
+    // Add the new option to the list if it doesn't already exist
+    if (
+      !options.some(
+        (opt) => opt.label.toLowerCase() === newOption.toLowerCase(),
+      )
+    ) {
+      setOptions((prev) => [...prev, newStock])
+      // Also add it to selected items
+      setSelectedItems((prev) => [...prev, newStock])
+      action('onAddNewOption')(newOption)
+    }
+  }
+
+  return (
+    <>
+      <Typography style={{ marginBottom: '1rem' }}>
+        Type a company name that's not in the list and press Enter or click the
+        "Add" option to add it as a new stock.
+      </Typography>
+
+      <Autocomplete
+        label="Select or add stocks"
+        options={options}
+        multiple
+        placeholder="Type to search or add new stocks..."
+        selectedOptions={selectedItems}
+        onOptionsChange={handleOptionsChange}
+        onAddNewOption={handleAddNewOption}
+        optionLabel={optionLabel}
+      />
+
+      {selectedItems.length > 0 && (
+        <div style={{ marginTop: '1rem' }}>
+          <Typography variant="body_short" style={{ marginBottom: '0.5rem' }}>
+            Selected stocks:
+          </Typography>
+          <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
+            {selectedItems.map((stock) => (
+              <Chip
+                key={stock.label}
+                onDelete={() =>
+                  setSelectedItems((prev) =>
+                    prev.filter((item) => item.label !== stock.label),
+                  )
+                }
+              >
+                {stock.trend} {stock.label} ({stock.symbol})
+              </Chip>
+            ))}
+          </div>
+        </div>
+      )}
+    </>
+  )
+}
+
+AddNewOption.storyName = 'Add new option'
+AddNewOption.args = {}


Test patch:
diff --git a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.test.tsx b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.test.tsx
index 520c59a0..f3cb1914 100644
--- a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.test.tsx
+++ b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.test.tsx
@@ -81,6 +81,10 @@ const waitForVirtualizedOptions = async (
   return validOptions.slice(0, expectedCount)
 }
 
+const StyledAutocomplete = styled(Autocomplete)`
+  clip-path: unset;
+`
+
 describe('Autocomplete', () => {
   it('Matches snapshot', async () => {
     render(<Autocomplete options={items} label={labelText} />)
@@ -279,40 +283,6 @@ describe('Autocomplete', () => {
     })
   })
 
-  it('Can add new options', async () => {
-    const onChange = jest.fn()
-    const onAddNewOption = jest.fn()
-    render(
-      <StyledAutocomplete
-        label={labelText}
-        options={items}
-        data-testid="styled-autocomplete"
-        onOptionsChange={onChange}
-        onAddNewOption={onAddNewOption}
-      />,
-    )
-
-    const labeledNodes = await screen.findAllByLabelText(labelText)
-    const input = labeledNodes[0]
-    const optionsList = labeledNodes[1]
-
-    const buttonNode = await screen.findByLabelText('toggle options', {
-      selector: 'button',
-    })
-
-    fireEvent.click(buttonNode)
-    fireEvent.change(input, {
-      target: { value: 'New option' },
-    })
-
-    const options = await within(optionsList).findAllByRole('option')
-    fireEvent.click(options[0])
-
-    await waitFor(() => {
-      expect(onAddNewOption).toHaveBeenNthCalledWith(1, 'New option')
-    })
-  })
-
   it('Can deselect complex options', async () => {
     const onChange = jest.fn()
     const opts = [
@@ -549,10 +519,6 @@ describe('Autocomplete', () => {
     expect(input).toHaveValue('')
   })
 
-  const StyledAutocomplete = styled(Autocomplete)`
-    clip-path: unset;
-  `
-
   it('Can extend the css for the component & props are passed correctly to input', async () => {
     const { container } = render(
       <StyledAutocomplete
@@ -571,3 +537,236 @@ describe('Autocomplete', () => {
     expect(autocomplete.nodeName).toBe('INPUT')
   })
 })
+describe('Autocomplete: Add new options feature', () => {
+  it('Can add new options', async () => {
+    const onChange = jest.fn()
+    const onAddNewOption = jest.fn()
+    render(
+      <StyledAutocomplete
+        label={labelText}
+        options={items}
+        data-testid="styled-autocomplete"
+        onOptionsChange={onChange}
+        onAddNewOption={onAddNewOption}
+      />,
+    )
+
+    const labeledNodes = await screen.findAllByLabelText(labelText)
+    const input = labeledNodes[0]
+    const optionsList = labeledNodes[1]
+
+    const buttonNode = await screen.findByLabelText('toggle options', {
+      selector: 'button',
+    })
+
+    fireEvent.click(buttonNode)
+    fireEvent.change(input, {
+      target: { value: 'New option' },
+    })
+
+    const options = await within(optionsList).findAllByRole('option')
+    fireEvent.click(options[0])
+
+    await waitFor(() => {
+      expect(onAddNewOption).toHaveBeenNthCalledWith(1, 'New option')
+    })
+  })
+  it('Does not show add option when input is empty', async () => {
+    const onAddNewOption = jest.fn()
+    render(
+      <StyledAutocomplete
+        label={labelText}
+        options={items}
+        data-testid="styled-autocomplete"
+        onAddNewOption={onAddNewOption}
+      />,
+    )
+
+    const buttonNode = await screen.findByLabelText('toggle options', {
+      selector: 'button',
+    })
+    fireEvent.click(buttonNode)
+
+    const addOption = screen.queryByTestId('add-item')
+    expect(addOption).not.toBeInTheDocument()
+  })
+
+  it('Shows add option only when no matches are found', async () => {
+    const onAddNewOption = jest.fn()
+    render(
+      <StyledAutocomplete
+        label={labelText}
+        options={items}
+        data-testid="styled-autocomplete"
+        onAddNewOption={onAddNewOption}
+      />,
+    )
+
+    const labeledNodes = await screen.findAllByLabelText(labelText)
+    const input = labeledNodes[0]
+    const buttonNode = await screen.findByLabelText('toggle options', {
+      selector: 'button',
+    })
+
+    fireEvent.click(buttonNode)
+
+    fireEvent.change(input, {
+      target: { value: 'One' }, // In test data
+    })
+
+    const addOption = screen.queryByTestId('add-item')
+    expect(addOption).not.toBeInTheDocument()
+
+    fireEvent.change(input, {
+      target: { value: 'Completely new item' },
+    })
+
+    const addOptionAfter = await screen.findByTestId('add-item')
+    expect(addOptionAfter).toBeInTheDocument()
+  })
+
+  it('Can add new option using arrow down and Enter key', async () => {
+    const onAddNewOption = jest.fn()
+    render(
+      <StyledAutocomplete
+        label={labelText}
+        options={items}
+        data-testid="styled-autocomplete"
+        onAddNewOption={onAddNewOption}
+      />,
+    )
+
+    const labeledNodes = await screen.findAllByLabelText(labelText)
+    const input = labeledNodes[0]
+    const optionsList = labeledNodes[1]
+    const buttonNode = await screen.findByLabelText('toggle options', {
+      selector: 'button',
+    })
+
+    fireEvent.click(buttonNode)
+    fireEvent.change(input, {
+      target: { value: 'New option via arrow+enter' },
+    })
+
+    const addOption = await within(optionsList).findByTestId('add-item')
+    expect(addOption).toBeInTheDocument()
+
+    // Use arrow down to highlight the add option, then Enter
+    fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' })
+    fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' })
+
+    await waitFor(() => {
+      expect(onAddNewOption).toHaveBeenCalledWith('New option via arrow+enter')
+    })
+  })
+
+  it('Does not call onAddNewOption with empty string', async () => {
+    const onAddNewOption = jest.fn()
+    render(
+      <StyledAutocomplete
+        label={labelText}
+        options={items}
+        data-testid="styled-autocomplete"
+        onAddNewOption={onAddNewOption}
+      />,
+    )
+
+    const labeledNodes = await screen.findAllByLabelText(labelText)
+    const input = labeledNodes[0]
+
+    fireEvent.focus(input)
+    fireEvent.change(input, {
+      target: { value: '   ' },
+    })
+
+    fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' })
+
+    expect(onAddNewOption).not.toHaveBeenCalled()
+  })
+
+  it('Clears input after adding new option', async () => {
+    const onAddNewOption = jest.fn()
+    render(
+      <StyledAutocomplete
+        label={labelText}
+        options={items}
+        data-testid="styled-autocomplete"
+        onAddNewOption={onAddNewOption}
+      />,
+    )
+
+    const labeledNodes = await screen.findAllByLabelText(labelText)
+    const input = labeledNodes[0] as HTMLInputElement
+    const optionsList = labeledNodes[1]
+    const buttonNode = await screen.findByLabelText('toggle options', {
+      selector: 'button',
+    })
+
+    fireEvent.click(buttonNode)
+    fireEvent.change(input, {
+      target: { value: 'New option to clear' },
+    })
+
+    const options = await within(optionsList).findAllByRole('option')
+    fireEvent.click(options[0])
+
+    await waitFor(() => {
+      expect(onAddNewOption).toHaveBeenCalledWith('New option to clear')
+    })
+    expect(input.value).toBe('')
+  })
+
+  it('Displays correct aria-label for add option', async () => {
+    const onAddNewOption = jest.fn()
+    render(
+      <StyledAutocomplete
+        label={labelText}
+        options={items}
+        data-testid="styled-autocomplete"
+        onAddNewOption={onAddNewOption}
+      />,
+    )
+
+    const labeledNodes = await screen.findAllByLabelText(labelText)
+    const input = labeledNodes[0]
+    const buttonNode = await screen.findByLabelText('toggle options', {
+      selector: 'button',
+    })
+
+    fireEvent.click(buttonNode)
+    fireEvent.change(input, {
+      target: { value: 'Test option' },
+    })
+
+    const addOption = await screen.findByTestId('add-item')
+    expect(addOption).toHaveAttribute(
+      'aria-label',
+      'Add new option: Test option',
+    )
+  })
+
+  it('Does not show add option when onAddNewOption is not provided', async () => {
+    render(
+      <StyledAutocomplete
+        label={labelText}
+        options={items}
+        data-testid="styled-autocomplete"
+        // no onAddNewOption prop
+      />,
+    )
+
+    const labeledNodes = await screen.findAllByLabelText(labelText)
+    const input = labeledNodes[0]
+    const buttonNode = await screen.findByLabelText('toggle options', {
+      selector: 'button',
+    })
+
+    fireEvent.click(buttonNode)
+    fireEvent.change(input, {
+      target: { value: 'Should not show add option' },
+    })
+
+    const addOption = screen.queryByTestId('add-item')
+    expect(addOption).not.toBeInTheDocument()
+  })
+})