diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.stories.ts b/packages/design-system/src/components/N8nSelectableList/SelectableList.stories.ts
new file mode 100644
index 0000000000..a4f5244143
--- /dev/null
+++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.stories.ts
@@ -0,0 +1,45 @@
+import type { StoryFn } from '@storybook/vue3';
+
+import N8nSelectableList from './SelectableList.vue';
+
+export default {
+ title: 'Modules/SelectableList',
+ component: N8nSelectableList,
+ argTypes: {},
+ parameters: {
+ backgrounds: { default: '--color-background-light' },
+ },
+};
+
+const Template: StoryFn = (args, { argTypes }) => ({
+ setup: () => ({
+ args: { ...args, modelValue: undefined },
+ model: args.modelValue,
+ }),
+ props: Object.keys(argTypes),
+ // Generics make this difficult to type
+ components: N8nSelectableList as never,
+ template:
+ 'Slot content for {{name}}',
+});
+
+export const SelectableList = Template.bind({});
+SelectableList.args = {
+ modelValue: {
+ propC: 'propC pre-existing initial value',
+ },
+ inputs: [
+ {
+ name: 'propC',
+ initialValue: 'propC default',
+ },
+ {
+ name: 'propB',
+ initialValue: 0,
+ },
+ {
+ name: 'propA',
+ initialValue: false,
+ },
+ ],
+};
diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts
new file mode 100644
index 0000000000..e7ba9d4206
--- /dev/null
+++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts
@@ -0,0 +1,107 @@
+import { fireEvent, render } from '@testing-library/vue';
+
+import N8nSelectableList from './SelectableList.vue';
+
+describe('N8nSelectableList', () => {
+ it('renders when empty', () => {
+ const wrapper = render(N8nSelectableList, {
+ props: {
+ modelValue: {},
+ inputs: [],
+ },
+ });
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('renders one clickable element that can be added and removed', async () => {
+ const wrapper = render(N8nSelectableList, {
+ props: {
+ modelValue: {},
+ inputs: [{ name: 'propA', initialValue: '' }],
+ },
+ });
+
+ expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument();
+
+ await fireEvent.click(wrapper.getByTestId('selectable-list-selectable-propA'));
+
+ expect(wrapper.queryByTestId('selectable-list-selectable-propA')).not.toBeInTheDocument();
+ expect(wrapper.getByTestId('selectable-list-slot-propA')).toBeInTheDocument();
+
+ await fireEvent.click(wrapper.getByTestId('selectable-list-remove-slot-propA'));
+
+ expect(wrapper.queryByTestId('selectable-list-slot-propA')).not.toBeInTheDocument();
+ });
+
+ it('renders multiple elements with some pre-selected', () => {
+ const wrapper = render(N8nSelectableList, {
+ props: {
+ modelValue: {
+ propC: false,
+ propA: 'propA value',
+ },
+ inputs: [
+ { name: 'propD', initialValue: true },
+ { name: 'propC', initialValue: true },
+ { name: 'propB', initialValue: 3 },
+ { name: 'propA', initialValue: '' },
+ ],
+ },
+ });
+
+ expect(wrapper.queryByTestId('selectable-list-selectable-propA')).not.toBeInTheDocument();
+ expect(wrapper.queryByTestId('selectable-list-selectable-propC')).not.toBeInTheDocument();
+ expect(wrapper.getByTestId('selectable-list-slot-propA')).toBeInTheDocument();
+ expect(wrapper.getByTestId('selectable-list-selectable-propB')).toBeInTheDocument();
+ expect(wrapper.getByTestId('selectable-list-slot-propC')).toBeInTheDocument();
+ expect(wrapper.getByTestId('selectable-list-selectable-propD')).toBeInTheDocument();
+
+ // This asserts order - specifically that propA appears before propC
+ expect(
+ wrapper
+ .getByTestId('selectable-list-slot-propA')
+ .compareDocumentPosition(wrapper.getByTestId('selectable-list-slot-propC')),
+ ).toEqual(4);
+
+ expect(
+ wrapper
+ .getByTestId('selectable-list-selectable-propB')
+ .compareDocumentPosition(wrapper.getByTestId('selectable-list-selectable-propD')),
+ ).toEqual(4);
+
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('renders disabled collection and clicks do not modify', async () => {
+ const wrapper = render(N8nSelectableList, {
+ props: {
+ modelValue: {
+ propB: 'propB value',
+ },
+ disabled: true,
+ inputs: [
+ { name: 'propA', initialValue: '' },
+ { name: 'propB', initialValue: '' },
+ { name: 'propC', initialValue: '' },
+ ],
+ },
+ });
+
+ expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument();
+ expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument();
+ expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument();
+ expect(wrapper.getByTestId('selectable-list-selectable-propC')).toBeInTheDocument();
+
+ await fireEvent.click(wrapper.getByTestId('selectable-list-selectable-propA'));
+
+ expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument();
+ expect(wrapper.queryByTestId('selectable-list-slot-propA')).not.toBeInTheDocument();
+
+ await fireEvent.click(wrapper.getByTestId('selectable-list-remove-slot-propB'));
+
+ expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument();
+ expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument();
+
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+});
diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.vue b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue
new file mode 100644
index 0000000000..e5ba985edf
--- /dev/null
+++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+ {{ t('selectableList.addDefault') }} {{ item.name }}
+
+
+
+
+
+
+
diff --git a/packages/design-system/src/components/N8nSelectableList/__snapshots__/SelectableList.test.ts.snap b/packages/design-system/src/components/N8nSelectableList/__snapshots__/SelectableList.test.ts.snap
new file mode 100644
index 0000000000..747dbeb778
--- /dev/null
+++ b/packages/design-system/src/components/N8nSelectableList/__snapshots__/SelectableList.test.ts.snap
@@ -0,0 +1,28 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`N8nSelectableList > renders disabled collection and clicks do not modify 1`] = `
+"
+
+ Add a propA+ Add a propC
+
+
"
+`;
+
+exports[`N8nSelectableList > renders multiple elements with some pre-selected 1`] = `
+"
+
+ Add a propB+ Add a propD
+
+
+
"
+`;
+
+exports[`N8nSelectableList > renders when empty 1`] = `
+""
+`;
diff --git a/packages/design-system/src/components/N8nSelectableList/index.ts b/packages/design-system/src/components/N8nSelectableList/index.ts
new file mode 100644
index 0000000000..6c2d0e1127
--- /dev/null
+++ b/packages/design-system/src/components/N8nSelectableList/index.ts
@@ -0,0 +1,3 @@
+import N8nSelectableList from './SelectableList.vue';
+
+export default N8nSelectableList;
diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts
index ce4360fe46..6b584b8e8e 100644
--- a/packages/design-system/src/components/index.ts
+++ b/packages/design-system/src/components/index.ts
@@ -33,6 +33,7 @@ export { default as N8nNodeCreatorNode } from './N8nNodeCreatorNode';
export { default as N8nNodeIcon } from './N8nNodeIcon';
export { default as N8nNotice } from './N8nNotice';
export { default as N8nOption } from './N8nOption';
+export { default as N8nSelectableList } from './N8nSelectableList';
export { default as N8nPopover } from './N8nPopover';
export { default as N8nPulse } from './N8nPulse';
export { default as N8nRadioButtons } from './N8nRadioButtons';
diff --git a/packages/design-system/src/locale/lang/en.ts b/packages/design-system/src/locale/lang/en.ts
index 35f2fd9f0e..3743bf92c6 100644
--- a/packages/design-system/src/locale/lang/en.ts
+++ b/packages/design-system/src/locale/lang/en.ts
@@ -51,4 +51,5 @@ export default {
'iconPicker.button.defaultToolTip': 'Choose icon',
'iconPicker.tabs.icons': 'Icons',
'iconPicker.tabs.emojis': 'Emojis',
+ 'selectableList.addDefault': '+ Add a',
} as N8nLocale;