diff --git a/packages/oui-angular/src/index.js b/packages/oui-angular/src/index.js
index 745f69b4..300d3744 100644
--- a/packages/oui-angular/src/index.js
+++ b/packages/oui-angular/src/index.js
@@ -33,6 +33,7 @@ import Slideshow from "@ovh-ui/oui-slideshow";
import Spinner from "@ovh-ui/oui-spinner";
import Stepper from "@ovh-ui/oui-stepper";
import Switch from "@ovh-ui/oui-switch";
+import Tabs from "@ovh-ui/oui-tabs";
import Textarea from "@ovh-ui/oui-textarea";
import Tile from "@ovh-ui/oui-tile";
import Tooltip from "@ovh-ui/oui-tooltip";
@@ -74,6 +75,7 @@ export default angular
Spinner,
Stepper,
Switch,
+ Tabs,
Textarea,
Tile,
Tooltip
diff --git a/packages/oui-angular/src/index.spec.js b/packages/oui-angular/src/index.spec.js
index 42ff6b48..4835b9e8 100644
--- a/packages/oui-angular/src/index.spec.js
+++ b/packages/oui-angular/src/index.spec.js
@@ -35,6 +35,7 @@ loadTests(require.context("../../oui-slideshow/src/", true, /.*((\.spec)|(index)
loadTests(require.context("../../oui-spinner/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-stepper/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-switch/src/", true, /.*((\.spec)|(index))$/));
+loadTests(require.context("../../oui-tabs/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-tile/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-textarea/src/", true, /.*((\.spec)|(index))$/));
loadTests(require.context("../../oui-tooltip/src/", true, /.*((\.spec)|(index))$/));
diff --git a/packages/oui-tabs/README.md b/packages/oui-tabs/README.md
new file mode 100644
index 00000000..260607c1
--- /dev/null
+++ b/packages/oui-tabs/README.md
@@ -0,0 +1,81 @@
+# Tabs
+
+
+
+## Usage
+
+### Basic
+
+```html:preview
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet congue urna. Praesent ultricies id ex convallis dictum. Pellentesque malesuada faucibus consectetur. Quisque vehicula tincidunt leo, quis auctor nisi luctus quis. Etiam purus lectus, placerat vitae vehicula at, molestie nec erat. Duis enim odio, maximus at laoreet in, finibus nec mi. Nam ultrices, lacus vitae egestas volutpat, libero odio gravida turpis, vitae dapibus ex mi nec quam. Pellentesque a tempor nibh.
+
+
+ Proin egestas fermentum lectus nec euismod. Vivamus eu congue dui. Pellentesque sit amet pellentesque quam. Morbi posuere sem nec rutrum placerat. Vestibulum porttitor arcu eu risus tempor consectetur. Fusce aliquam bibendum aliquet. Morbi semper egestas iaculis. Ut sit amet sem et neque porta cursus pellentesque nec augue. Nullam semper in metus et luctus. Nunc molestie non ipsum a consequat. Etiam pellentesque laoreet lectus ut luctus. Nulla maximus, leo a mattis gravida, ligula felis vulputate libero, vitae fringilla nibh mauris nec dui. Fusce sed massa at arcu euismod dictum id sit amet lorem. Aliquam sed viverra sem, quis vehicula ligula. Vivamus blandit varius condimentum.
+
+
+ Duis egestas nulla at euismod semper. Nullam bibendum auctor viverra. Sed posuere neque nulla, id cursus nisi molestie vel. Nulla ornare elit sit amet congue faucibus. Aliquam eget lorem id justo ornare pretium in sit amet lectus. Sed maximus odio id porttitor rhoncus. Quisque pulvinar mauris ut sapien dictum, ultrices fermentum orci efficitur. Cras nec auctor ante. Aliquam ornare eleifend neque, at condimentum lacus aliquet elementum. Mauris mattis porttitor tortor vel vehicula. Phasellus venenatis nibh nec viverra sollicitudin. Ut lobortis mattis mauris, vel euismod nibh faucibus a.
+
+
+```
+
+### Dynamic
+
+```html:preview
+
+
+ {{$ctrl.hideDynamic1 ? 'Show' : 'Hide'}} Dynamic1 tab
+
+
+ {{$ctrl.hideDynamic2 ? 'Show' : 'Hide'}} Dynamic2 tab
+
+
+ {{$ctrl.hideDynamic3 ? 'Show' : 'Hide'}} Dynamic3 tab
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam sit amet congue urna. Praesent ultricies id ex convallis dictum. Pellentesque malesuada faucibus consectetur. Quisque vehicula tincidunt leo, quis auctor nisi luctus quis. Etiam purus lectus, placerat vitae vehicula at, molestie nec erat. Duis enim odio, maximus at laoreet in, finibus nec mi. Nam ultrices, lacus vitae egestas volutpat, libero odio gravida turpis, vitae dapibus ex mi nec quam. Pellentesque a tempor nibh.
+
+
+ Proin egestas fermentum lectus nec euismod. Vivamus eu congue dui. Pellentesque sit amet pellentesque quam. Morbi posuere sem nec rutrum placerat. Vestibulum porttitor arcu eu risus tempor consectetur. Fusce aliquam bibendum aliquet. Morbi semper egestas iaculis. Ut sit amet sem et neque porta cursus pellentesque nec augue. Nullam semper in metus et luctus. Nunc molestie non ipsum a consequat. Etiam pellentesque laoreet lectus ut luctus. Nulla maximus, leo a mattis gravida, ligula felis vulputate libero, vitae fringilla nibh mauris nec dui. Fusce sed massa at arcu euismod dictum id sit amet lorem. Aliquam sed viverra sem, quis vehicula ligula. Vivamus blandit varius condimentum.
+
+
+ Duis egestas nulla at euismod semper. Nullam bibendum auctor viverra. Sed posuere neque nulla, id cursus nisi molestie vel. Nulla ornare elit sit amet congue faucibus. Aliquam eget lorem id justo ornare pretium in sit amet lectus. Sed maximus odio id porttitor rhoncus. Quisque pulvinar mauris ut sapien dictum, ultrices fermentum orci efficitur. Cras nec auctor ante. Aliquam ornare eleifend neque, at condimentum lacus aliquet elementum. Mauris mattis porttitor tortor vel vehicula. Phasellus venenatis nibh nec viverra sollicitudin. Ut lobortis mattis mauris, vel euismod nibh faucibus a.
+
+
+```
+
+### Check marks
+
+```html:preview
+
+
+ Toggle check mark of tab 1
+
+
+ Toggle check mark of tab 2
+
+
+ Toggle check mark of tab 3
+
+
+```
+
+## API
+
+### oui-tabs
+
+| Attribute | Type | Binding | One-time Binding | Values | Default | Description
+| ---- | ---- | ---- | ---- | ---- | ---- | ----
+| `aria-label` | string | @? | yes | n/a | n/a | accessibility label
+
+### oui-tabs-item
+
+| Attribute | Type | Binding | One-time Binding | Values | Default | Description
+| ---- | ---- | ---- | ---- | ---- | ---- | ----
+| `id` | string | @? | yes | n/a | n/a | id attribute of the panel
+| `heading` | string | @? | yes | n/a | n/a | heading text of the tab
+| `aria-label` | string | @? | yes | n/a | n/a | accessibility label
+| `checked` | booldean | | yes | `true`, `false` | n/a | check mark flag of the tab
diff --git a/packages/oui-tabs/package.json b/packages/oui-tabs/package.json
new file mode 100644
index 00000000..8477b7aa
--- /dev/null
+++ b/packages/oui-tabs/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "@ovh-ui/oui-tabs",
+ "version": "1.0.0",
+ "main": "./src/index.js",
+ "license": "BSD-3-Clause",
+ "author": "OVH SAS"
+}
diff --git a/packages/oui-tabs/src/index.js b/packages/oui-tabs/src/index.js
new file mode 100644
index 00000000..102c8ec5
--- /dev/null
+++ b/packages/oui-tabs/src/index.js
@@ -0,0 +1,8 @@
+import Tabs from "./tabs.component";
+import TabsItem from "./item/tabs-item.component";
+
+export default angular
+ .module("oui.tabs", [])
+ .component("ouiTabs", Tabs)
+ .component("ouiTabsItem", TabsItem)
+ .name;
diff --git a/packages/oui-tabs/src/index.spec.js b/packages/oui-tabs/src/index.spec.js
new file mode 100644
index 00000000..8b07de8e
--- /dev/null
+++ b/packages/oui-tabs/src/index.spec.js
@@ -0,0 +1,133 @@
+describe("ouiTabs", () => {
+ let TestUtils;
+ let $timeout;
+
+ beforeEach(angular.mock.module("oui.tabs"));
+ beforeEach(angular.mock.module("oui.test-utils"));
+
+ beforeEach(inject((_TestUtils_, _$timeout_) => {
+ TestUtils = _TestUtils_;
+ $timeout = _$timeout_;
+ }));
+
+ describe("Components", () => {
+ it("should add default classname", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+ `);
+
+ $timeout.flush();
+
+ expect(element.hasClass("oui-tabs")).toBeTruthy();
+ expect(element.find("oui-tabs-item").hasClass("oui-tabs-item")).toBeTruthy();
+ });
+
+ it("should have accessibility attribute", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+ `);
+
+ $timeout.flush();
+
+ const content = angular.element(element[0].querySelector(".oui-tabs-item__content"));
+ const contentId = content.attr("id");
+ expect(content.attr("role")).toBe("tabpanel");
+ expect(content.attr("aria-hidden")).toBeDefined();
+
+ const tablist = angular.element(element[0].querySelector(".oui-tabs__tablist"));
+ expect(tablist.attr("role")).toBe("tablist");
+ expect(tablist.attr("aria-label")).toBe("tablist");
+
+ const tab = angular.element(element[0].querySelector(".oui-tabs__tab"));
+ expect(tab.attr("aria-label")).toBe("heading");
+ expect(tab.attr("aria-controls")).toBe(contentId);
+ expect(tab.attr("aria-selected")).toBeDefined();
+
+ const button = angular.element(element[0].querySelector(".oui-tabs-item__button"));
+ expect(button.attr("aria-label")).toBe("heading");
+ expect(button.attr("aria-controls")).toBe(contentId);
+ expect(button.attr("aria-expanded")).toBeDefined();
+ });
+
+ it("should set tabs with heading", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+
+ `);
+
+ $timeout.flush();
+
+ const items = element.find("oui-tabs-item");
+ const tabs = angular.element(element[0].querySelector(".oui-tabs__tablist")).find("button");
+ expect(tabs.length).toBe(items.length);
+
+ angular.forEach(items, (item, index) => {
+ const heading = angular.element(item).attr("heading");
+ const tab = angular.element(tabs[index]);
+ expect(tab.text().trim()).toBe(heading);
+ });
+ });
+
+ it("should update active tab", () => {
+ const element = TestUtils.compileTemplate(`
+
+
+
+ `);
+
+ $timeout.flush();
+
+ const items = element.find("oui-tabs-item");
+ const button = angular.element(items[1]).find("button");
+ button.triggerHandler("click");
+
+ const id = button.next().attr("id");
+ const tabsCtrl = element.controller("ouiTabs");
+ expect(tabsCtrl.activeId).toBe(id);
+ });
+
+ it("should update checkmark", () => {
+ const checked = [true, false];
+ const element = TestUtils.compileTemplate(`
+
+
+
+ `);
+
+ $timeout.flush();
+
+ const items = element.find("oui-tabs-item");
+ angular.forEach(items, (item, index) => {
+ const heading = angular.element(item).find("button");
+ expect(heading.hasClass("oui-tabs-item__button_checked")).toBe(checked[index]);
+ });
+ });
+ });
+
+ describe("Methods", () => {
+ it("should add item in items array", () => {
+ const element = TestUtils.compileTemplate(" ");
+ const tabsCtrl = element.controller("ouiTabs");
+
+ tabsCtrl.items = ["ipsum"];
+ tabsCtrl.addItem("lorem", 0); // Should be added at index 0
+ tabsCtrl.addItem("dolor"); // Should be added at the end
+
+ expect(tabsCtrl.items.indexOf("lorem")).toBe(0);
+ expect(tabsCtrl.items.indexOf("dolor")).toBe(tabsCtrl.items.length - 1);
+ });
+
+ it("should remove item in items array", () => {
+ const element = TestUtils.compileTemplate(" ");
+ const tabsCtrl = element.controller("ouiTabs");
+
+ tabsCtrl.items = ["lorem", "ipsum", "dolor"];
+ tabsCtrl.removeItem("ipsum");
+
+ expect(tabsCtrl.items.indexOf("ipsum")).toBe(-1);
+ });
+ });
+});
diff --git a/packages/oui-tabs/src/item/tabs-item.component.js b/packages/oui-tabs/src/item/tabs-item.component.js
new file mode 100644
index 00000000..0209960f
--- /dev/null
+++ b/packages/oui-tabs/src/item/tabs-item.component.js
@@ -0,0 +1,17 @@
+import controller from "./tabs-item.controller";
+import template from "./tabs-item.html";
+
+export default {
+ require: {
+ tabsCtrl: "^ouiTabs"
+ },
+ bindings: {
+ id: "@?",
+ heading: "@?",
+ ariaLabel: "@?",
+ checked: ""
+ },
+ controller,
+ template,
+ transclude: true
+};
diff --git a/packages/oui-tabs/src/item/tabs-item.controller.js b/packages/oui-tabs/src/item/tabs-item.controller.js
new file mode 100644
index 00000000..3609118d
--- /dev/null
+++ b/packages/oui-tabs/src/item/tabs-item.controller.js
@@ -0,0 +1,64 @@
+import { addBooleanParameter, addDefaultParameter } from "@ovh-ui/common/component-utils";
+
+export default class {
+ constructor ($attrs, $element, $scope, $timeout) {
+ "ngInject";
+
+ this.$attrs = $attrs;
+ this.$element = $element;
+ this.$scope = $scope;
+ this.$timeout = $timeout;
+ }
+
+ setActive () {
+ this.tabsCtrl.activeId = this.id;
+ }
+
+ getNodeIndex () {
+ let i = 0;
+ let item = this.$element[0];
+
+ // Return DOM node index
+ while (item.previousSibling) {
+ item = item.previousSibling;
+ if (item.nodeType === 1) {
+ i += 1;
+ }
+ }
+
+ return i;
+ }
+
+ $onInit () {
+ addBooleanParameter(this, "checked");
+ addDefaultParameter(this, "id", `ouiTabsItem${this.$scope.$id}`);
+
+ this.hasCheckmark = angular.isDefined(this.$attrs.checked);
+
+ // Watch if hidden
+ this.$scope.$watch(
+ () => this.tabsCtrl.activeId,
+ (value) => {
+ this.isHidden = value !== this.id;
+ }
+ );
+ }
+
+ $onDestroy () {
+ if (this.tabsCtrl) {
+ this.tabsCtrl.removeItem(this);
+ }
+ }
+
+ $postLink () {
+ this.$timeout(() =>
+ this.$element.addClass("oui-tabs-item")
+ );
+
+ if (this.tabsCtrl) {
+ // Add item to parent oui-tabs and give node index
+ // To support ngIf directive, which delay the adding
+ this.tabsCtrl.addItem(this, this.getNodeIndex());
+ }
+ }
+}
diff --git a/packages/oui-tabs/src/item/tabs-item.html b/packages/oui-tabs/src/item/tabs-item.html
new file mode 100644
index 00000000..17db8856
--- /dev/null
+++ b/packages/oui-tabs/src/item/tabs-item.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/oui-tabs/src/tabs.component.js b/packages/oui-tabs/src/tabs.component.js
new file mode 100644
index 00000000..827c19f3
--- /dev/null
+++ b/packages/oui-tabs/src/tabs.component.js
@@ -0,0 +1,13 @@
+import controller from "./tabs.controller";
+import template from "./tabs.html";
+
+export default {
+ controller,
+ template,
+ bindings: {
+ ariaLabel: "@?"
+ },
+ transclude: {
+ item: "?ouiTabsItem"
+ }
+};
diff --git a/packages/oui-tabs/src/tabs.controller.js b/packages/oui-tabs/src/tabs.controller.js
new file mode 100644
index 00000000..a4291f33
--- /dev/null
+++ b/packages/oui-tabs/src/tabs.controller.js
@@ -0,0 +1,51 @@
+export default class {
+ constructor ($element, $timeout) {
+ "ngInject";
+
+ this.$element = $element;
+ this.$timeout = $timeout;
+ }
+
+ addItem (item, index) {
+ // Position item in items array
+ if (angular.isNumber(index)) {
+ this.items.splice(index, 0, item);
+ } else {
+ this.items.push(item);
+ }
+
+ // Set first added tab active
+ if (this.items.length === 1) {
+ this.setActiveTab(item.id);
+ }
+ }
+
+ removeItem (item) {
+ const index = this.items.indexOf(item);
+
+ if (index > -1) {
+ this.items.splice(index, 1);
+ }
+
+ // If was activeId, set first item as active
+ if (this.items.length && item.id === this.activeId) {
+ this.setActiveTab(this.items[0].id);
+ }
+ }
+
+ setActiveTab (id) {
+ this.activeId = id;
+ }
+
+ $onInit () {
+ this.items = [];
+ }
+
+ $postLink () {
+ this.$timeout(() =>
+ this.$element
+ .addClass("oui-tabs")
+ .removeAttr("aria-label")
+ );
+ }
+}
diff --git a/packages/oui-tabs/src/tabs.html b/packages/oui-tabs/src/tabs.html
new file mode 100644
index 00000000..c9def067
--- /dev/null
+++ b/packages/oui-tabs/src/tabs.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+