diff --git a/packages/oui-angular/src/index.js b/packages/oui-angular/src/index.js
index 2b5f6d77..c32f6756 100644
--- a/packages/oui-angular/src/index.js
+++ b/packages/oui-angular/src/index.js
@@ -14,6 +14,7 @@ import Field from "@ovh-ui/oui-field";
import FormActions from "@ovh-ui/oui-form-actions";
import GuideMenu from "@ovh-ui/oui-guide-menu";
import HeaderTabs from "@ovh-ui/oui-header-tabs";
+import InlineAdder from "@ovh-ui/oui-inline-adder";
import Message from "@ovh-ui/oui-message";
import Modal from "@ovh-ui/oui-modal";
import Navbar from "@ovh-ui/oui-navbar";
@@ -53,6 +54,7 @@ export default angular
FormActions,
GuideMenu,
HeaderTabs,
+ InlineAdder,
Message,
Modal,
Navbar,
diff --git a/packages/oui-angular/src/index.spec.js b/packages/oui-angular/src/index.spec.js
index ed8ea126..67b46a72 100644
--- a/packages/oui-angular/src/index.spec.js
+++ b/packages/oui-angular/src/index.spec.js
@@ -16,6 +16,7 @@ loadTests(require.context("../../oui-field/src/", true, /.*((\.spec)|(index))$/)
loadTests(require.context("../../oui-form-actions/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-guide-menu/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-header-tabs/src/", true, /.*((\.spec)|(index))$/));
+loadTests(require.context("../../oui-inline-adder/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-message/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-modal/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-navbar/src/", true, /.*((\.spec)|(index))$/));
diff --git a/packages/oui-inline-adder/README.md b/packages/oui-inline-adder/README.md
new file mode 100644
index 00000000..76292d0f
--- /dev/null
+++ b/packages/oui-inline-adder/README.md
@@ -0,0 +1,168 @@
+# Inline adder
+
+
+
+## Usage
+
+### Basic
+
+```html:preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Multiple rows
+
+```html:preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Adaptive fields
+
+**Note**: Fields with `adaptive` attribute will adapt their size to their content.
+
+```html:preview
+
+
+
+
+
+
+
+
+
+
+
+ Select the OS
+ FreeBSD
+ Linux
+ OSX
+ Windows
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Events
+
+#### `on-add`
+
+**Note**: If you want to access the form inside `on-add` callback, you need to use the `form` variable as below.
+
+```html:preview
+
+
+
+
+
+
+
+
+
+
+
On Add
+
{{$ctrl.addedForm | json}}
+
+```
+
+#### `on-remove`
+
+**Note**: If you want to access the form inside `on-remove` callback, you need to use the `form` variable as below.
+
+```html:preview
+
+
+
+
+
+
+
+
+
+
+
On Remove
+
{{$ctrl.removedForm | json}}
+
+```
+
+#### `on-change`
+
+**Note**: If you want to access the forms array inside `on-change` callback, you need to use the `forms` variable as below.
+
+```html:preview
+
+
+
+
+
+
+
+
+
+
+
On Change
+
{{$ctrl.changedForms | json}}
+
+```
+
+## API
+
+### oui-inline-adder
+
+| Attribute | Type | Binding | One-time binding | Values | Default | Description
+| ---- | ---- | ---- | ---- | ---- | ---- | ----
+| `on-add` | function | & | no | n/a | n/a | handler triggered when a row is added
+| `on-remove` | function | & | no | n/a | n/a | handler triggered when a row is removed
+| `on-change` | function | & | no | n/a | n/a | handler triggered when rows have changed
+
+### oui-inline-adder-field
+
+| Attribute | Type | Binding | One-time binding | Values | Default | Description
+| ---- | ---- | ---- | ---- | ---- | ---- | ----
+| `adaptive` | boolean | | yes | `true`, `false` | `false` | adaptive field flag
diff --git a/packages/oui-inline-adder/src/field/inline-adder-field.component.js b/packages/oui-inline-adder/src/field/inline-adder-field.component.js
new file mode 100644
index 00000000..669b5e7a
--- /dev/null
+++ b/packages/oui-inline-adder/src/field/inline-adder-field.component.js
@@ -0,0 +1,8 @@
+import controller from "./inline-adder-field.controller";
+
+export default {
+ bindings: {
+ adaptive: ""
+ },
+ controller
+};
diff --git a/packages/oui-inline-adder/src/field/inline-adder-field.controller.js b/packages/oui-inline-adder/src/field/inline-adder-field.controller.js
new file mode 100644
index 00000000..dd2b31dc
--- /dev/null
+++ b/packages/oui-inline-adder/src/field/inline-adder-field.controller.js
@@ -0,0 +1,25 @@
+import { addBooleanParameter } from "@ovh-ui/common/component-utils";
+
+export default class {
+ constructor ($attrs, $element, $timeout) {
+ "ngInject";
+
+ this.$attrs = $attrs;
+ this.$element = $element;
+ this.$timeout = $timeout;
+ }
+
+ $onInit () {
+ addBooleanParameter(this, "adaptive");
+ }
+
+ $postLink () {
+ this.$timeout(() => {
+ this.$element.addClass("oui-inline-adder__field");
+
+ if (this.adaptive) {
+ this.$element.addClass("oui-inline-adder__field_adaptive");
+ }
+ });
+ }
+}
diff --git a/packages/oui-inline-adder/src/index.js b/packages/oui-inline-adder/src/index.js
new file mode 100644
index 00000000..d2b791a2
--- /dev/null
+++ b/packages/oui-inline-adder/src/index.js
@@ -0,0 +1,12 @@
+import InlineAdder from "./inline-adder.component";
+import InlineAdderField from "./field/inline-adder-field.component";
+import InlineAdderProvider from "./inline-adder.provider";
+import InlineAdderRow from "./row/inline-adder-row.component";
+
+export default angular
+ .module("oui.inline-adder", [])
+ .component("ouiInlineAdder", InlineAdder)
+ .component("ouiInlineAdderField", InlineAdderField)
+ .component("ouiInlineAdderRow", InlineAdderRow)
+ .provider("ouiInlineAdderConfiguration", InlineAdderProvider)
+ .name;
diff --git a/packages/oui-inline-adder/src/index.spec.js b/packages/oui-inline-adder/src/index.spec.js
new file mode 100644
index 00000000..f3a16ac3
--- /dev/null
+++ b/packages/oui-inline-adder/src/index.spec.js
@@ -0,0 +1,176 @@
+describe("ouiInlineAdder", () => {
+ let TestUtils;
+ let $timeout;
+ let configuration;
+
+ beforeEach(angular.mock.module("oui.inline-adder"));
+ beforeEach(angular.mock.module("oui.inline-adder.configuration"));
+ beforeEach(angular.mock.module("oui.test-utils"));
+
+ beforeEach(inject((_TestUtils_, _$timeout_) => {
+ TestUtils = _TestUtils_;
+ $timeout = _$timeout_;
+ }));
+
+ describe("Provider", () => {
+ angular.module("oui.inline-adder.configuration", [
+ "oui.inline-adder"
+ ]).config(ouiInlineAdderConfigurationProvider => {
+ ouiInlineAdderConfigurationProvider.setTranslations({
+ foo: "bar"
+ });
+ });
+
+ beforeEach(inject(_ouiInlineAdderConfiguration_ => {
+ configuration = _ouiInlineAdderConfiguration_;
+ }));
+
+ it("should have custom options", () => {
+ expect(configuration.translations.foo).toEqual("bar");
+ });
+ });
+
+ describe("Component", () => {
+ describe("oui-inline-adder", () => {
+ it("should have a default classname", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+
+
+
+
+
+
+ `);
+
+ $timeout.flush();
+
+ expect(element.hasClass("oui-inline-adder")).toBeTruthy();
+ });
+
+ it("should have a form with a default id/name", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+
+
+
+
+
+
+ `);
+
+ const form = element.find("form");
+ const id = `${element.controller("ouiInlineAdder").id}_0`;
+ const name = `${element.controller("ouiInlineAdder").name}_0`;
+
+ expect(form.length).toBe(1);
+ expect(form.attr("id")).toBe(id);
+ expect(form.attr("name")).toBe(name);
+ });
+
+ it("should create a new form when submitted", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+
+
+
+
+
+
+ `);
+
+ let length = element.find("form").length;
+
+ // Will have 2 rows
+ angular.element(element.find("form")[length - 1]).triggerHandler("submit");
+ expect(element.find("form").length).toBe(length += 1);
+
+ // Will have 3 rows
+ angular.element(element.find("form")[length - 1]).triggerHandler("submit");
+ expect(element.find("form").length).toBe(length += 1);
+ });
+
+ it("should hide a form when removed", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+
+
+
+
+
+
+ `);
+
+ let length = element.find("form").length;
+ const form = angular.element(element.find("form")[0]);
+
+ // Will have 2 rows
+ form.triggerHandler("submit");
+ expect(element.find("form").length).toBe(length += 1);
+
+ // Will have 1 row hidden
+ form.find("button").triggerHandler("click");
+ expect(form.hasClass("ng-hide")).toBeTruthy();
+ });
+ });
+
+ describe("oui-inline-adder-row", () => {
+ it("should have a default classname", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+
+
+
+
+
+
+ `);
+
+ $timeout.flush();
+
+ expect(element.find("oui-inline-adder-row").hasClass("oui-inline-adder__row")).toBeTruthy();
+ });
+ });
+
+ describe("oui-inline-adder-field", () => {
+ it("should have a default classname", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+
+
+
+
+
+
+ `);
+
+ $timeout.flush();
+
+ expect(element.find("oui-inline-adder-field").hasClass("oui-inline-adder__field")).toBeTruthy();
+ });
+
+ it("should have a variant classname", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+
+
+
+
+
+
+ `);
+
+ $timeout.flush();
+
+ expect(element.find("oui-inline-adder-field").hasClass("oui-inline-adder__field_adaptive")).toBeTruthy();
+ });
+ });
+ });
+});
diff --git a/packages/oui-inline-adder/src/inline-adder.component.js b/packages/oui-inline-adder/src/inline-adder.component.js
new file mode 100644
index 00000000..0a60a478
--- /dev/null
+++ b/packages/oui-inline-adder/src/inline-adder.component.js
@@ -0,0 +1,15 @@
+import controller from "./inline-adder.controller.js";
+import template from "./inline-adder.html";
+
+export default {
+ bindings: {
+ id: "@?",
+ name: "@?",
+ onAdd: "&",
+ onChange: "&",
+ onRemove: "&"
+ },
+ controller,
+ template,
+ transclude: true
+};
diff --git a/packages/oui-inline-adder/src/inline-adder.controller.js b/packages/oui-inline-adder/src/inline-adder.controller.js
new file mode 100644
index 00000000..e8bf4b53
--- /dev/null
+++ b/packages/oui-inline-adder/src/inline-adder.controller.js
@@ -0,0 +1,57 @@
+import { addDefaultParameter } from "@ovh-ui/common/component-utils";
+import filter from "lodash/filter";
+
+export default class {
+ constructor ($attrs, $element, $scope, $timeout, ouiInlineAdderConfiguration) {
+ "ngInject";
+
+ this.$attrs = $attrs;
+ this.$element = $element;
+ this.$scope = $scope;
+ this.$timeout = $timeout;
+ this.translations = ouiInlineAdderConfiguration.translations;
+ }
+
+ $onInit () {
+ this.forms = [true];
+ this.isDisabled = [false];
+
+ addDefaultParameter(this, "id", `ouiInlineAdderForm${this.$scope.$id}`);
+ addDefaultParameter(this, "name", `ouiInlineAdderForm${this.$scope.$id}`);
+ }
+
+ $postLink () {
+ this.$timeout(() =>
+ this.$element.addClass("oui-inline-adder")
+ );
+ }
+
+ onFormsChange () {
+ // Filter boolean values used for ngShow
+ const forms = filter(this.forms, (item) => angular.isObject(item));
+ this.onChange({ forms });
+ }
+
+ onFormSubmit (form, index) {
+ if (form.$valid) {
+ this.forms[index] = form;
+
+ // Create new instance of form
+ this.isDisabled[index] = true;
+ this.forms.push(true);
+
+ // Callbacks
+ this.onAdd({ form });
+ this.onFormsChange();
+ }
+ }
+
+ onFormRemove (form, index) {
+ // Hide removed form to avoid refreshing ngRepeat
+ this.forms[index] = false;
+
+ // Callback
+ this.onRemove({ form });
+ this.onFormsChange();
+ }
+}
diff --git a/packages/oui-inline-adder/src/inline-adder.html b/packages/oui-inline-adder/src/inline-adder.html
new file mode 100644
index 00000000..9502e156
--- /dev/null
+++ b/packages/oui-inline-adder/src/inline-adder.html
@@ -0,0 +1,24 @@
+
diff --git a/packages/oui-inline-adder/src/inline-adder.provider.js b/packages/oui-inline-adder/src/inline-adder.provider.js
new file mode 100644
index 00000000..c2810a0f
--- /dev/null
+++ b/packages/oui-inline-adder/src/inline-adder.provider.js
@@ -0,0 +1,25 @@
+import { merge } from "lodash";
+
+export default class {
+ constructor () {
+ this.translations = {
+ ariaAddItem: "Add Item",
+ ariaRemoveItem: "Remove Item"
+ };
+ }
+
+ /**
+ * Set the translations
+ * @param {Object} translations a map of translations
+ */
+ setTranslations (translations) {
+ this.translations = merge(this.translations, translations);
+ return this;
+ }
+
+ $get () {
+ return {
+ translations: this.translations
+ };
+ }
+}
diff --git a/packages/oui-inline-adder/src/row/inline-adder-row.component.js b/packages/oui-inline-adder/src/row/inline-adder-row.component.js
new file mode 100644
index 00000000..4a62ecae
--- /dev/null
+++ b/packages/oui-inline-adder/src/row/inline-adder-row.component.js
@@ -0,0 +1,16 @@
+export default {
+ controller: class {
+ constructor ($element, $timeout) {
+ "ngInject";
+
+ this.$element = $element;
+ this.$timeout = $timeout;
+ }
+
+ $postLink () {
+ this.$timeout(() =>
+ this.$element.addClass("oui-inline-adder__row")
+ );
+ }
+ }
+};