diff --git a/.storybook/main.js b/.storybook/main.js
index f7474ad1863d70f32f423810ab69b45545b150dd..f6442bd0a117165ead825d16d182e0a2296ac960 100644
--- a/.storybook/main.js
+++ b/.storybook/main.js
@@ -1,3 +1,4 @@
+const webpack = require("webpack");
 const MiniCssExtractPlugin = require("mini-css-extract-plugin");
 const appConfig = require("../webpack.config");
 
@@ -17,7 +18,13 @@ module.exports = {
   babel: () => {},
   webpackFinal: storybookConfig => ({
     ...storybookConfig,
-    plugins: [...storybookConfig.plugins, new MiniCssExtractPlugin()],
+    plugins: [
+      ...storybookConfig.plugins,
+      new MiniCssExtractPlugin(),
+      new webpack.ProvidePlugin({
+        Buffer: ["buffer", "Buffer"],
+      }),
+    ],
     module: {
       ...storybookConfig.module,
       rules: [
diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.tsx
index 335105d2730989e31e11c91cdc41b9d399b20e9a..87b6fb3124ab7871a8c08235962fced8814c4865 100644
--- a/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.tsx
+++ b/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.tsx
@@ -28,11 +28,11 @@ interface FilterValuePickerProps<T> {
   onChange: (newValues: T[]) => void;
   onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
   onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
+  shouldCreate?: (query: string, values: string[]) => boolean;
 }
 
 interface FilterValuePickerOwnProps extends FilterValuePickerProps<string> {
   placeholder: string;
-  shouldCreate: (query: string) => boolean;
 }
 
 function FilterValuePicker({
@@ -115,7 +115,7 @@ export function StringFilterValuePicker({
   values,
   ...props
 }: FilterValuePickerProps<string>) {
-  const shouldCreate = (query: string) => {
+  const shouldCreate = (query: string, values: string[]) => {
     return query.trim().length > 0 && !values.includes(query);
   };
 
@@ -136,9 +136,9 @@ export function NumberFilterValuePicker({
   onChange,
   ...props
 }: FilterValuePickerProps<number>) {
-  const shouldCreate = (query: string) => {
+  const shouldCreate = (query: string, values: string[]) => {
     const number = parseFloat(query);
-    return isFinite(number) && !values.includes(number);
+    return isFinite(number) && !values.includes(query);
   };
 
   return (
diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx
index 87c8375349104073932f091b63d95f3c0c6f9f57..35cf4641435e9f333d4a429793c1a4b64a4c1b1b 100644
--- a/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx
+++ b/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx
@@ -525,7 +525,9 @@ describe("StringFilterValuePicker", () => {
         },
       });
 
-      await userEvent.type(screen.getByLabelText("Filter value"), "a@b.com");
+      const input = screen.getByLabelText("Filter value");
+      await userEvent.type(input, "a@b.com");
+      input.blur();
       expect(onChange).toHaveBeenLastCalledWith(["a@b.com"]);
     });
 
diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx
index e123d4ab9c9e21cb2f201b6be404e912688b0550..7e2435314ad42ba5ab96a3716cfd55e46759a9eb 100644
--- a/frontend/src/metabase/querying/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx
+++ b/frontend/src/metabase/querying/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx
@@ -22,7 +22,7 @@ interface ListValuePickerProps {
   fieldValues: FieldValue[];
   selectedValues: string[];
   placeholder?: string;
-  shouldCreate: (query: string) => boolean;
+  shouldCreate?: (query: string, values: string[]) => boolean;
   autoFocus?: boolean;
   compact?: boolean;
   onChange: (newValues: string[]) => void;
diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/SearchValuePicker/SearchValuePicker.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/SearchValuePicker/SearchValuePicker.tsx
index 4ba5519cf26e72ea9fab1b820eba5d3432fb062e..6346d8da4922b097434ffd1abfa982f1e8c8f5a0 100644
--- a/frontend/src/metabase/querying/components/FilterValuePicker/SearchValuePicker/SearchValuePicker.tsx
+++ b/frontend/src/metabase/querying/components/FilterValuePicker/SearchValuePicker/SearchValuePicker.tsx
@@ -17,7 +17,7 @@ interface SearchValuePickerProps {
   fieldValues: FieldValue[];
   selectedValues: string[];
   placeholder?: string;
-  shouldCreate: (query: string) => boolean;
+  shouldCreate?: (query: string, values: string[]) => boolean;
   autoFocus?: boolean;
   onChange: (newValues: string[]) => void;
 }
diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/StaticValuePicker/StaticValuePicker.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/StaticValuePicker/StaticValuePicker.tsx
index 2abd4f42255f0df4a8a2c41b5018fb6297bcd6c0..4c25fbe085c5611fbeabe87eac182523628c8101 100644
--- a/frontend/src/metabase/querying/components/FilterValuePicker/StaticValuePicker/StaticValuePicker.tsx
+++ b/frontend/src/metabase/querying/components/FilterValuePicker/StaticValuePicker/StaticValuePicker.tsx
@@ -6,7 +6,7 @@ import { MultiAutocomplete } from "metabase/ui";
 interface StaticValuePickerProps {
   selectedValues: string[];
   placeholder?: string;
-  shouldCreate: (query: string) => boolean;
+  shouldCreate?: (query: string, values: string[]) => boolean;
   autoFocus?: boolean;
   onChange: (newValues: string[]) => void;
   onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.module.css b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.module.css
new file mode 100644
index 0000000000000000000000000000000000000000..b246050915a732402d9aaf314688a9e964471b2a
--- /dev/null
+++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.module.css
@@ -0,0 +1,3 @@
+.icon {
+  fill: var(--color-text-light);
+}
diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.tsx b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.tsx
index 5849dc75004646153e5f8d9dae35325d7e55bbd3..495adc6f95408269c0f98ae729640f7f561d8cf3 100644
--- a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.tsx
+++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.tsx
@@ -1,10 +1,16 @@
 import type { MultiSelectProps, SelectItem } from "@mantine/core";
-import { MultiSelect } from "@mantine/core";
+import { MultiSelect, Tooltip } from "@mantine/core";
 import { useUncontrolled } from "@mantine/hooks";
 import type { ClipboardEvent, FocusEvent } from "react";
 import { useMemo, useState } from "react";
+import { t } from "ttag";
 
-type MultiAutocompleteProps = Omit<MultiSelectProps, "shouldCreate"> & {
+import { Icon } from "metabase/ui";
+
+import styles from "./MultiAutocomplete.module.css";
+import { parseValues, unique } from "./utils";
+
+export type MultiAutocompleteProps = Omit<MultiSelectProps, "shouldCreate"> & {
   shouldCreate?: (query: string, selectedValues: string[]) => boolean;
 };
 
@@ -35,7 +41,7 @@ export function MultiAutocomplete({
   });
   const [lastSelectedValues, setLastSelectedValues] = useState(selectedValues);
   const [isFocused, setIsFocused] = useState(false);
-  const visibleValues = isFocused ? lastSelectedValues : selectedValues;
+  const visibleValues = isFocused ? lastSelectedValues : [...selectedValues];
 
   const items = useMemo(
     () => getAvailableSelectItems(data, lastSelectedValues),
@@ -43,8 +49,9 @@ export function MultiAutocomplete({
   );
 
   const handleChange = (newValues: string[]) => {
-    setSelectedValues(newValues);
-    setLastSelectedValues(newValues);
+    const values = unique(newValues);
+    setSelectedValues(values);
+    setLastSelectedValues(values);
   };
 
   const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
@@ -53,40 +60,110 @@ export function MultiAutocomplete({
     onFocus?.(event);
   };
 
+  function isValid(value: string) {
+    return value !== "" && shouldCreate?.(value, lastSelectedValues);
+  }
+
   const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
     setIsFocused(false);
-    setLastSelectedValues(selectedValues);
+
+    const values = parseValues(searchValue);
+    const validValues = values.filter(isValid);
+
     setSearchValue("");
+
+    if (validValues.length > 0) {
+      const newValues = unique([...lastSelectedValues, ...validValues]);
+      setSelectedValues(newValues);
+      setLastSelectedValues(newValues);
+    } else {
+      setSelectedValues(lastSelectedValues);
+    }
+
     onBlur?.(event);
   };
 
-  const handleSearchChange = (newSearchValue: string) => {
-    setSearchValue(newSearchValue);
+  const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
+    event.preventDefault();
+
+    const input = event.target as HTMLInputElement;
+    const value = input.value;
+    const before = value.slice(0, input.selectionStart ?? value.length);
+    const after = value.slice(input.selectionEnd ?? value.length);
 
-    const isValid = shouldCreate?.(newSearchValue, selectedValues);
-    if (isValid) {
-      setSelectedValues([...lastSelectedValues, newSearchValue]);
+    const pasted = event.clipboardData.getData("text");
+    const text = `${before}${pasted}${after}`;
+
+    const values = parseValues(text);
+    const validValues = values.filter(isValid);
+
+    if (values.length > 0) {
+      const newValues = unique([...lastSelectedValues, ...validValues]);
+      setSelectedValues(newValues);
+      setLastSelectedValues(newValues);
+      setSearchValue("");
     } else {
-      setSelectedValues(lastSelectedValues);
+      setSearchValue(text);
     }
   };
 
-  const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
-    const text = event.clipboardData.getData("Text");
-    const values = text.split(/[\n,]/g);
-    if (values.length > 1) {
-      const validValues = [...new Set(values)]
-        .map(value => value.trim())
-        .filter(value => shouldCreate?.(value, selectedValues));
+  const handleSearchChange = (newSearchValue: string) => {
+    const first = newSearchValue.at(0);
+    const last = newSearchValue.at(-1);
+
+    setSearchValue(newSearchValue);
+
+    if (newSearchValue !== "") {
+      const values = parseValues(newSearchValue);
+      if (values.length >= 1) {
+        const value = values[0] ?? newSearchValue;
+        if (isValid(value)) {
+          setSelectedValues(unique([...lastSelectedValues, value]));
+        }
+      }
+    }
+    if (newSearchValue === "") {
+      setSelectedValues(unique([...lastSelectedValues]));
+    }
+
+    const quotes = Array.from(newSearchValue).filter(ch => ch === '"').length;
+
+    if (
+      (last === "," && quotes % 2 === 0) ||
+      last === "\t" ||
+      last === "\n" ||
+      (first === '"' && last === '"')
+    ) {
+      const values = parseValues(newSearchValue);
+      const validValues = values.filter(isValid);
+
+      if (values.length > 0) {
+        setSearchValue("");
+      }
+
       if (validValues.length > 0) {
-        event.preventDefault();
-        const newSelectedValues = [...lastSelectedValues, ...validValues];
-        setSelectedValues(newSelectedValues);
-        setLastSelectedValues(newSelectedValues);
+        const newValues = unique([...lastSelectedValues, ...validValues]);
+        setSelectedValues(newValues);
+        setLastSelectedValues(newValues);
+        setSearchValue("");
       }
     }
   };
 
+  const info = (
+    <Tooltip
+      label={
+        <>
+          {t`Separate values with commas, tabs or newlines.`}
+          <br />
+          {t` Use double quotes for values containing commas.`}
+        </>
+      }
+    >
+      <Icon name="info_filled" className={styles.icon} />
+    </Tooltip>
+  );
+
   return (
     <MultiSelect
       {...props}
@@ -101,35 +178,38 @@ export function MultiAutocomplete({
       onBlur={handleBlur}
       onSearchChange={handleSearchChange}
       onPaste={handlePaste}
+      rightSection={info}
     />
   );
 }
 
 function getSelectItem(item: string | SelectItem): SelectItem {
   if (typeof item === "string") {
-    return { value: item };
-  } else {
-    return item;
+    return { value: item, label: item };
+  }
+
+  if (!item.label) {
+    return { value: item.value, label: item.value };
   }
+
+  return item;
 }
 
 function getAvailableSelectItems(
   data: ReadonlyArray<string | SelectItem>,
   selectedValues: string[],
 ) {
-  const items = [
-    ...data.map(getSelectItem),
-    ...selectedValues.map(getSelectItem),
-  ];
-
-  const mapping = items.reduce((map: Map<string, string>, option) => {
-    if (!map.has(option.value)) {
-      map.set(option.value, option.label ?? option.value);
-    }
-    return map;
-  }, new Map<string, string>());
+  const all = [...data, ...selectedValues].map(getSelectItem);
+  const seen = new Set();
 
-  return [...mapping.entries()].map(([value, label]) => ({ value, label }));
+  // Deduplicate items based on value
+  return all.filter(function (option) {
+    if (seen.has(option.value)) {
+      return false;
+    }
+    seen.add(option.value);
+    return true;
+  });
 }
 
 function defaultShouldCreate(query: string, selectedValues: string[]) {
diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.unit.spec.tsx b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.unit.spec.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bc1a25e719658d7f67e9461886bbc3cb4dde331f
--- /dev/null
+++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.unit.spec.tsx
@@ -0,0 +1,285 @@
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+
+import { render, screen } from "__support__/ui";
+import { MultiAutocomplete, type MultiAutocompleteProps } from "metabase/ui";
+
+const EXAMPLE_DATA = [
+  { label: "Foo", value: "foo" },
+  { label: "Bar", value: "bar" },
+  { label: "Bar (2)", value: "bar-2" },
+];
+
+type SetupOpts = Omit<TestInputProps, "onChange">;
+
+function setup(opts: SetupOpts) {
+  const onChange = jest.fn();
+  render(<TestInput {...opts} onChange={onChange} aria-label="Filter value" />);
+
+  const input = screen.getByRole("searchbox");
+  return { onChange, input };
+}
+
+type TestInputProps = MultiAutocompleteProps & {
+  initialValue?: string[];
+};
+
+function TestInput(props: TestInputProps) {
+  const [value, setValue] = useState(props.initialValue ?? []);
+
+  function handleChange(value: string[]) {
+    setValue(value);
+    props.onChange?.(value);
+  }
+
+  return <MultiAutocomplete {...props} value={value} onChange={handleChange} />;
+}
+
+describe("MultiAutocomplete", () => {
+  it("should accept values when blurring", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    await userEvent.type(input, "foo", {
+      pointerEventsCheck: 0,
+    });
+    input.blur();
+
+    expect(onChange).toHaveBeenCalledWith(["foo"]);
+    expect(input).toHaveValue("");
+
+    await userEvent.type(input, "bar", {
+      pointerEventsCheck: 0,
+    });
+    input.blur();
+
+    expect(onChange).toHaveBeenCalledWith(["foo", "bar"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should not accept values when blurring if they are not accepted by shouldCreate", async () => {
+    const { input, onChange } = setup({
+      data: EXAMPLE_DATA,
+      shouldCreate(value: string) {
+        return value === "foo";
+      },
+    });
+    await userEvent.type(input, "foo", {
+      pointerEventsCheck: 0,
+    });
+    input.blur();
+
+    expect(onChange).toHaveBeenLastCalledWith(["foo"]);
+    expect(input).toHaveValue("");
+
+    // this one does _not_ trigger a change
+    await userEvent.type(input, "bar", {
+      pointerEventsCheck: 0,
+    });
+    input.blur();
+
+    expect(onChange).toHaveBeenLastCalledWith(["foo"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should accept a value when no comma has been entered", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    await userEvent.type(input, "foo", {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(["foo"]);
+
+    await userEvent.type(input, ",bar", {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]);
+  });
+
+  it("should accept values when entering a comma", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    await userEvent.type(input, "foo,", {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(["foo"]);
+    expect(input).toHaveValue("");
+
+    await userEvent.type(input, "bar,", {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should accept quote-delimited values containing commas", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    await userEvent.type(input, '"foo bar",', {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(["foo bar"]);
+    expect(input).toHaveValue("");
+
+    await userEvent.type(input, "baz,", {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(["foo bar", "baz"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should correctly parse escaped quotes", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    await userEvent.type(input, '"foo \\"bar\\"",', {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(['foo "bar"']);
+    expect(input).toHaveValue("");
+
+    await userEvent.type(input, "baz,", {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(['foo "bar"', "baz"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should accept quote-delimited values containing commas when pasting", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    input.focus();
+    await userEvent.paste('"foo bar",baz');
+    expect(onChange).toHaveBeenLastCalledWith(["foo bar", "baz"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should correctly parse escaped quotes when pasting", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    input.focus();
+    await userEvent.paste('"foo \\"bar\\"",baz');
+    expect(onChange).toHaveBeenLastCalledWith(['foo "bar"', "baz"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should accept values with spaces in them", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    await userEvent.type(input, "foo bar,", {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(["foo bar"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should not accept values when entering a comma if they are not accepted by shouldCreate", async () => {
+    const { input, onChange } = setup({
+      data: EXAMPLE_DATA,
+      shouldCreate(value: string) {
+        return value === "foo";
+      },
+    });
+    await userEvent.type(input, "foo,", {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(["foo"]);
+    expect(input).toHaveValue("");
+
+    // this one does _not_ trigger a change
+    await userEvent.type(input, "bar,", {
+      pointerEventsCheck: 0,
+    });
+    expect(onChange).toHaveBeenLastCalledWith(["foo"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should accept comma-separated values when pasting", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    input.focus();
+    await userEvent.paste("foo,bar");
+    expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should accept newline-separated values when pasting", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    input.focus();
+    await userEvent.paste("foo\nbar");
+    expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should accept tab-separated values when pasting", async () => {
+    const { input, onChange } = setup({ data: EXAMPLE_DATA });
+    input.focus();
+    await userEvent.paste("foo\tbar");
+    expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should accept comma-separated values, but omit values not accepted by shouldCreate", async () => {
+    const { input, onChange } = setup({
+      data: EXAMPLE_DATA,
+      shouldCreate(value: string) {
+        return value === "foo" || value === "bar";
+      },
+    });
+    input.focus();
+    await userEvent.paste("foo,bar,baz");
+    expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should handle pasting when there is some text in the input", async () => {
+    const { input, onChange } = setup({
+      data: EXAMPLE_DATA,
+    });
+    await userEvent.type(input, "foo123", {
+      pointerEventsCheck: 0,
+    });
+
+    input.focus();
+    // @ts-expect-error: input does have setSelectionRange, and testing-library does not provide a wrapper
+    input.setSelectionRange(3, 3);
+    await userEvent.paste("quu,xyz");
+
+    expect(onChange).toHaveBeenLastCalledWith(["fooquu", "xyz123"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should be possible to paste a partial value", async () => {
+    const { input } = setup({
+      data: EXAMPLE_DATA,
+    });
+
+    input.focus();
+    await userEvent.paste('"quu');
+
+    expect(input).toHaveValue('"quu');
+  });
+
+  it("should not be possible to enter duplicate values", async () => {
+    const { input, onChange } = setup({
+      data: EXAMPLE_DATA,
+    });
+
+    input.focus();
+    await userEvent.type(input, "a,a,b,b,a,a,", {
+      pointerEventsCheck: 0,
+    });
+
+    expect(onChange).toHaveBeenLastCalledWith(["a", "b"]);
+    expect(input).toHaveValue("");
+  });
+
+  it("should respect RTL languages when pasting", async () => {
+    const { input, onChange } = setup({
+      data: EXAMPLE_DATA,
+      dir: "rtl",
+    });
+
+    input.focus();
+    await userEvent.type(input, "כּטקמ", {
+      pointerEventsCheck: 0,
+    });
+    expect(input).toHaveValue("כּטקמ");
+
+    // @ts-expect-error: input does have setSelectionRange, and testing-library does not provide a wrapper
+    input.setSelectionRange(3, 3);
+    await userEvent.paste("×¥,×£");
+
+    expect(onChange).toHaveBeenLastCalledWith(["כּטץ", "ףקמ"]);
+    expect(input).toHaveValue("");
+  });
+});
diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.ts b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e5c800d02788a1f8c77397a5591fd5d4d9b584fb
--- /dev/null
+++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.ts
@@ -0,0 +1,21 @@
+import { parse } from "csv-parse/browser/esm/sync";
+
+export function parseValues(str: string): string[] {
+  try {
+    return parse(str, {
+      delimiter: [",", "\t", "\n"],
+      skip_empty_lines: true,
+      relax_column_count: true,
+      relax_quotes: true,
+      trim: true,
+      quote: '"',
+      escape: "\\",
+    }).flat();
+  } catch (err) {
+    return [];
+  }
+}
+
+export function unique(values: string[]): string[] {
+  return Array.from(new Set(values));
+}
diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.unit.spec.ts b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.unit.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f95f9c899ecac0ebb23754d37ddba33589e3701d
--- /dev/null
+++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.unit.spec.ts
@@ -0,0 +1,24 @@
+import { parseValues } from "./utils";
+
+describe("parseValues", () => {
+  it("should parse comma-separated values", () => {
+    expect(parseValues(``)).toEqual([]);
+    expect(parseValues(`foo`)).toEqual(["foo"]);
+    expect(parseValues(`foo,`)).toEqual(["foo", ""]);
+    expect(parseValues(`foo,bar`)).toEqual(["foo", "bar"]);
+    expect(parseValues(`foo,bar,`)).toEqual(["foo", "bar", ""]);
+    expect(parseValues(`foo,bar,baz`)).toEqual(["foo", "bar", "baz"]);
+  });
+
+  it('should allow escaping commas with "', () => {
+    expect(parseValues(`"bar,baz"`)).toEqual(["bar,baz"]);
+    expect(parseValues(`foo,"bar,baz"`)).toEqual(["foo", "bar,baz"]);
+    expect(parseValues(`"bar,baz",quu`)).toEqual(["bar,baz", "quu"]);
+  });
+
+  it("should allow escaping quotes commas with \\", () => {
+    expect(parseValues(`"\\"baz\\""`)).toEqual(['"baz"']);
+    expect(parseValues(`"bar,\\"baz\\""`)).toEqual(['bar,"baz"']);
+    expect(parseValues(`"\\"baz\\",quu"`)).toEqual(['"baz",quu']);
+  });
+});
diff --git a/jest.config.js b/jest.config.js
index ff7e1830f9f8559f77f30956ec0ad9b027f149cb..e322800dd7076cd4e3fb7b85c42d6a73be563c2e 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -13,6 +13,8 @@ const config = {
       "<rootDir>/node_modules/react-markdown/react-markdown.min.js",
     "\\.svg\\?(component|source)":
       "<rootDir>/frontend/test/__mocks__/svgMock.jsx",
+    "csv-parse/browser/esm/sync":
+      "<rootDir>/node_modules/csv-parse/dist/cjs/sync",
   },
   transformIgnorePatterns: [
     "<rootDir>/node_modules/(?!(screenfull|echarts|zrender|rehype-external-links|hast.*|devlop|property-information|comma-separated-tokens|space-separated-tokens|vfile|vfile-message|html-void-elements|stringify-entities|character-entities-html4)/)",
diff --git a/package.json b/package.json
index b2427b0e47c61119df30ef43290a42b7bd767363..a0b5579ec907f7ab6d10ef6076fc404c8d1e13dc 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
     "cron-expression-validator": "^1.0.20",
     "cronstrue": "^2.11.0",
     "crossfilter": "^1.3.12",
+    "csv-parse": "^5.5.6",
     "d3": "^3.5.17",
     "d3-array": "^3.1.1",
     "d3-scale": "^3.3.0",
diff --git a/yarn.lock b/yarn.lock
index 8770da6e5b9c9dcb0a76c273a5e5eb2fde027dd6..545ca8add1591e3adc46cf10b996cf5c1185c07d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9275,6 +9275,11 @@ csstype@^3.0.6:
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9"
   integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
 
+csv-parse@^5.5.6:
+  version "5.5.6"
+  resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.5.6.tgz#0d726d58a60416361358eec291a9f93abe0b6b1a"
+  integrity sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==
+
 cuint@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"