diff --git a/packages/oui-header-tabs/README.md b/packages/oui-header-tabs/README.md index 8abb0d32..7e2d249c 100644 --- a/packages/oui-header-tabs/README.md +++ b/packages/oui-header-tabs/README.md @@ -92,7 +92,7 @@ | ---- | ---- | ---- | ---- | ---- | ---- | ---- | `text` | string | @ | yes | | | display the menu item with this text | `href` | string | @? | yes | | | href of the menu item -| `state` | boolean | @? | yes | | | state of the menu item +| `state` | string | @? | yes | | | state of the menu item | `stateParams` | object | - ``` -Note: All children menus have `.oui-navbar-menu_fixed`. The component is intended to be used in `fixed` mode. To avoid being hidden by the documentation navbar, this example is not `fixed`. +### Advanced -## API +```html:preview + + + + + + + + + + + + + + +``` + +## Brand -| Attribute | Type | Binding | One-time Binding | Values | Default | Description | -| ---- | ---- | ---- | ---- | ---- | ---- | ---- | -| brand | object | + +``` ### Properties of attribute `brand` -- `label` _(optional)_: define `aria-label` of the brand link. -- `title`: _(optional)_: define the brand text. +- `label` **(optional)**: define `aria-label` of the brand link. +- `title`: **(optional)**: define the brand text. - `url`: define `href` of the brand link. #### Set a brand icon with a CSS class (for `oui-icon`) @@ -41,8 +101,6 @@ Note: All children menus have `.oui-navbar-menu_fixed`. The component is intende The brand icon will be set as a ``. -###### Example - ```json { "label": String, @@ -54,14 +112,12 @@ The brand icon will be set as a ``. #### Set a brand icon with an image -- `iconAlt` _(optional)_: define `alt` of the brand icon. -- `iconClass` _(optional)_: define `class` of the brand icon. +- `iconAlt` **(optional)**: define `alt` of the brand icon. +- `iconClass` **(optional)**: define `class` of the brand icon. - `iconSrc`: define `src` of the brand icon. The brand icon will be set as a ``. -###### Example - ```json { "label": String, @@ -73,17 +129,30 @@ The brand icon will be set as a ``. } ``` +### With component `oui-navbar-brand` + +```html:preview + + + +``` + +## Links + ### Common properties of attributes `*-links` - `name`: define the navigation name of a menu. -- `class` _(optional)_: define `class` of the menu item (only used for root links). -- `label` _(optional)_: define `aria-label` of the menu item. +- `class` **(optional)**: define `class` of the menu item (only used for root links). +- `label` **(optional)**: define `aria-label` of the menu item. - `title`: define the menu item text. -- `headerTitle` _(optional)_: define the title of the menu header (default text is `title`). -- `headerBreadcrumb` _(optional)_: define the breadcrumb of the menu header. -- `headerTemplate` _(optional)_: define the HTML template of the menu header. -- `isActive` _(optional)_: define if the menu item has active variant `.oui-navbar-menu__item_active`. -- `acknowledged` _(optional)_: define if the menu item is acknowledged. +- `headerTitle` **(optional)**: define the title of the menu header (default text is `title`). +- `headerBreadcrumb` **(optional)**: define the breadcrumb of the menu header. +- `headerTemplate` **(optional)**: define the HTML template of the menu header. +- `isActive` **(optional)**: define if the menu item has active variant `.oui-navbar-menu__item_active`. +- `acknowledged` **(optional)**: define if the menu item is acknowledged. If `headerTemplate` is defined, `headerBreadcrumb` and `headerTitle` are not used. @@ -93,8 +162,6 @@ If `headerTemplate` is defined, `headerBreadcrumb` and `headerTitle` are not use The menu item will be set as a ``. -##### Example - ```json { "name": String, @@ -108,12 +175,10 @@ The menu item will be set as a ``. #### Set a menu item as a link for ui-router - `state`: define `ui-sref` of the menu item. The menu item will be set as a ``, `click` and `url` will be ignored. -- `stateParams` _(optional)_: define parameters for `state`. +- `stateParams` **(optional)**: define parameters for `state`. The menu item will be set as a ``. -##### Example - ```json { "name": String, @@ -131,8 +196,6 @@ The menu item will be set as a ``. The menu item will be set as a ` +
diff --git a/packages/oui-navbar/src/group/navbar-group.controller.js b/packages/oui-navbar/src/group/navbar-group.controller.js index f603a64e..176afce1 100644 --- a/packages/oui-navbar/src/group/navbar-group.controller.js +++ b/packages/oui-navbar/src/group/navbar-group.controller.js @@ -1,11 +1,57 @@ export default class { - constructor ($attrs, $element, ouiNavbarConfiguration, NavbarGroupService) { + constructor ($attrs, $element, ouiNavbarConfiguration, KEYBOARD_KEYS) { "ngInject"; this.$attrs = $attrs; this.$element = $element; this.config = ouiNavbarConfiguration; - this.navbarGroupService = NavbarGroupService; + + this.KEYBOARD_KEYS = KEYBOARD_KEYS; + } + + bindGroup (groupName) { + const keys = {}; + const keysRegex = new RegExp([ + this.KEYBOARD_KEYS.TAB, + this.KEYBOARD_KEYS.SHIFT + ].join("|")); + + const tabbableItems = this.navbarCtrl.getGroup(groupName); + const lastIndex = tabbableItems.length - 1; + const focusElement = (e, groupIndex) => { + let index = groupIndex; + keys[e.which] = true; + + if (keys[this.KEYBOARD_KEYS.TAB] && !keys[this.KEYBOARD_KEYS.SHIFT]) { + // Move Down + index = index >= lastIndex ? 0 : index + 1; + } else if (keys[this.KEYBOARD_KEYS.TAB] && keys[this.KEYBOARD_KEYS.SHIFT]) { + // Move Up + index = index <= 0 ? lastIndex : index - 1; + } + + // Check if element is visible + if (tabbableItems[index].clientHeight) { + tabbableItems[index].focus(); + } else { + focusElement(e, index); + } + }; + + angular.element(tabbableItems) + .on("keydown", (e) => { + if (keysRegex.test(e.which) && this.isOpen(groupName)) { + e.preventDefault(); + focusElement(e, this.navbarCtrl.getGroup(groupName).indexOf(e.target)); + } + }) + .on("keyup", (e) => { + delete keys[e.which]; + }); + } + + isOpen (state) { + return this.navbarCtrl.navigation && this.navbarCtrl.navigation[state]; } $onInit () { @@ -16,11 +62,11 @@ export default class { } $postLink () { - this.navbarGroupService.addItemToGroup(this.$element[0], this.groupName); + this.navbarCtrl.addItemToGroup(this.$element[0], this.groupName); // Bind items when it's the last item if (this.isLast) { - this.navbarGroupService.bindGroup(this.groupName); + this.bindGroup(this.groupName); } } } diff --git a/packages/oui-navbar/src/group/navbar-group.directive.js b/packages/oui-navbar/src/group/navbar-group.directive.js index 54cdbee9..d476a6e2 100644 --- a/packages/oui-navbar/src/group/navbar-group.directive.js +++ b/packages/oui-navbar/src/group/navbar-group.directive.js @@ -2,6 +2,9 @@ import controller from "./navbar-group.controller"; export default () => ({ restrict: "A", + require: { + navbarCtrl: "^ouiNavbar" + }, bindToController: { groupName: "@ouiNavbarGroup", isLast: " { - let index = groupIndex; - keys[e.which] = true; - - if (keys[this.KEYBOARD_KEYS.ALT] && !keys[this.KEYBOARD_KEYS.TAB]) { - // Move Down - index = index >= lastIndex ? 0 : index + 1; - } else if (keys[this.KEYBOARD_KEYS.ALT] && keys[this.KEYBOARD_KEYS.TAB]) { - // Move Up - index = index <= 0 ? lastIndex : index - 1; - } - - // Check if element is visible - if (tabbableItems[index].clientHeight) { - tabbableItems[index].focus(); - } else { - focusElement(e, index); - } - }; - - angular.element(tabbableItems) - .on("keydown", (e) => { - if (keysRegex.test(e.which) && this.navbarService.isOpen(groupName)) { - e.preventDefault(); - focusElement(e, this.keyboardNav[groupName].indexOf(e.target)); - } - }) - .on("keyup", (e) => { - delete keys[e.which]; - }); - } - - // Set focus to an item of a group - setFocusTo (groupName, index) { - // Add a delay to force focus - const delay = 50; - this.$timeout(() => this.keyboardNav[groupName][index] && this.keyboardNav[groupName][index].focus(), delay); - } -} diff --git a/packages/oui-navbar/src/index.js b/packages/oui-navbar/src/index.js index abddba4c..99b858bc 100644 --- a/packages/oui-navbar/src/index.js +++ b/packages/oui-navbar/src/index.js @@ -1,10 +1,15 @@ import KEYBOARD_KEYS from "./keyboard-keys.constant"; + import Navbar from "./navbar.component"; -import NavbarConfigurationProvider from "./navbar.provider.js"; +import NavbarBrand from "./brand/navbar-brand.component"; +import NavbarConfigurationProvider from "./navbar.provider"; +import NavbarDropdown from "./dropdown/navbar-dropdown.component"; +import NavbarDropdownMenu from "./dropdown/menu/navbar-dropdown-menu.component"; import NavbarGroup from "./group/navbar-group.directive"; -import NavbarGroupService from "./group/navbar-group.service"; +import NavbarLink from "./link/navbar-link.component"; import NavbarMenu from "./menu/navbar-menu.component"; -import NavbarService from "./navbar.service"; +import NavbarNotification from "./notification/navbar-notification.component"; +import NavbarToggler from "./toggler/navbar-toggler.component"; export default angular .module("oui.navbar", [ @@ -13,9 +18,13 @@ export default angular ]) .constant("KEYBOARD_KEYS", KEYBOARD_KEYS) .component("ouiNavbar", Navbar) - .directive("ouiNavbarGroup", NavbarGroup) + .component("ouiNavbarBrand", NavbarBrand) + .component("ouiNavbarDropdown", NavbarDropdown) + .component("ouiNavbarDropdownMenu", NavbarDropdownMenu) + .component("ouiNavbarLink", NavbarLink) .component("ouiNavbarMenu", NavbarMenu) + .component("ouiNavbarNotification", NavbarNotification) + .component("ouiNavbarToggler", NavbarToggler) + .directive("ouiNavbarGroup", NavbarGroup) .provider("ouiNavbarConfiguration", NavbarConfigurationProvider) - .service("NavbarService", NavbarService) - .service("NavbarGroupService", NavbarGroupService) .name; diff --git a/packages/oui-navbar/src/index.spec.js b/packages/oui-navbar/src/index.spec.js index 8fd210be..c3e67f15 100644 --- a/packages/oui-navbar/src/index.spec.js +++ b/packages/oui-navbar/src/index.spec.js @@ -2,29 +2,255 @@ import mockData from "./index.spec.data.json"; describe("ouiNavbar", () => { let testUtils; + let $document; + let $timeout; + let configuration; beforeEach(angular.mock.module("oui.navbar")); + beforeEach(angular.mock.module("oui.navbar.configuration")); beforeEach(angular.mock.module("oui.test-utils")); - beforeEach(inject((_TestUtils_) => { + beforeEach(inject((_$document_, _$timeout_, _TestUtils_) => { + $document = _$document_; + $timeout = _$timeout_; testUtils = _TestUtils_; })); + describe("Provider", () => { + angular.module("oui.navbar.configuration", [ + "oui.navbar" + ]).config(ouiNavbarConfigurationProvider => { + ouiNavbarConfigurationProvider.setTranslations({ + foo: "bar" + }); + }); + + beforeEach(inject(_ouiNavbarConfiguration_ => { + configuration = _ouiNavbarConfiguration_; + })); + + it("should have custom options", () => { + expect(configuration.translations.foo).toEqual("bar"); + }); + }); + describe("Component", () => { + const navbarClass = "oui-navbar"; + const navbarFixedClass = `${navbarClass}_fixed`; + const navbarDropdownClass = `${navbarClass}-dropdown`; + const navbarMenuClass = `${navbarClass}-menu`; + const navbarMenuFixedClass = `${navbarMenuClass}_fixed`; + const navbarMenuEndClass = `${navbarMenuClass}_end`; + const navbarListItemClass = `${navbarClass}-list__item`; + + it("should display a navbar", () => { + const component = testUtils.compileTemplate(` + + `, { + brand: mockData.brand, + activeLink: mockData.mainLinks[0].name, + mainLinks: mockData.mainLinks, + asideLinks: mockData.asideLinks, + togglerLinks: mockData.togglerLinks + }); + const controller = component.controller("ouiNavbar"); + + $timeout.flush(); + + expect(angular.copy(controller.brand)).toEqual(mockData.brand); + expect(angular.copy(controller.activeLink)).toEqual(mockData.mainLinks[0].name); + expect(angular.copy(controller.mainLinks)).toEqual(mockData.mainLinks); + expect(angular.copy(controller.asideLinks)).toEqual(mockData.asideLinks); + expect(angular.copy(controller.togglerLinks)).toEqual(mockData.togglerLinks); + }); + + describe("Navbar", () => { + const item = "foo"; + const groupName = "bar"; + const menuName = "lorem"; + const internalMenuName = "ipsum"; + + let component; + let controller; + + beforeEach(() => { + component = testUtils.compileTemplate(""); + controller = component.controller("ouiNavbar"); + + $timeout.flush(); + }); + + it("should have default classname", () => { + expect(component.hasClass(navbarClass)).toBeTruthy(); + expect(component.hasClass(navbarFixedClass)).toBeFalsy(); + }); + + it("should have role 'navigation'", () => { + expect(component.attr("role")).toBe("navigation"); + }); + + it("should add item 'foo' to group 'bar'", () => { + controller.addItemToGroup(item, groupName); + + expect(Array.isArray(controller.keyboardNav[groupName])).toBeTruthy(); + expect(controller.keyboardNav[groupName].length).toBe(1); + expect(controller.keyboardNav[groupName][0]).toBe(item); + }); + + it("should return group 'bar'", () => { + controller.addItemToGroup(item, groupName); + const group = controller.getGroup(groupName); + + expect(Array.isArray(group)).toBeTruthy(); + expect(group.length).toBe(1); + expect(group[0]).toBe(item); + }); + + it("should toggle navigation state of Menu 'lorem' ", () => { + expect(controller.navigation).toBeUndefined(); + + controller.toggleMenu(menuName); + expect(controller.navigation[menuName]).toBeTruthy(); + + controller.toggleMenu(menuName); + expect(controller.navigation).toBeNull(); + }); + + it("should toggle navigation state of internal Menu 'lorem'", () => { + controller.toggleMenu(menuName); + controller.toggleMenu(internalMenuName, true); + expect(controller.navigation[internalMenuName]).toBeTruthy(); + + controller.toggleMenu(internalMenuName, true); + expect(controller.navigation[internalMenuName]).toBeFalsy(); + }); + + it("should clear navigation", () => { + controller.toggleMenu(menuName); + controller.toggleMenu(internalMenuName, true); + controller.toggleMenu(); + expect(controller.navigation).toBeNull(); + + // Simulate external click + controller.toggleMenu(menuName); + controller.toggleMenu(internalMenuName, true); + $document.triggerHandler("click"); + + $timeout.flush(); + expect(controller.navigation).toBeNull(); + + // Simulate ESC keydown + controller.toggleMenu(menuName); + controller.toggleMenu(internalMenuName, true); + $document.triggerHandler({ + type: "keydown", + which: controller.KEYBOARD_KEYS.ESC + }); + + $timeout.flush(); + expect(controller.navigation).toBeNull(); + }); + + it("should not clear navigation", () => { + controller.toggleMenu(menuName); + controller.toggleMenu(internalMenuName, true); + + const navigation = controller.navigation; + component.triggerHandler("click"); + + $timeout.flush(); + expect(controller.navigation).toEqual(navigation); + }); + + it("should be fixed", () => { + // Test without value, should be true + component = testUtils.compileTemplate(""); + controller = component.controller("ouiNavbar"); + + $timeout.flush(); + + expect(component.hasClass(navbarFixedClass)).toBeTruthy(); + expect(controller.fixed).toBeTruthy(); + + // Test with value + component = testUtils.compileTemplate(''); + controller = component.controller("ouiNavbar"); + + $timeout.flush(); + + expect(component.hasClass(navbarFixedClass)).toBeTruthy(); + expect(controller.fixed).toBeTruthy(); + }); + + it("should not be fixed", () => { + component = testUtils.compileTemplate(''); + controller = component.controller("ouiNavbar"); + + $timeout.flush(); + + expect(component.hasClass(navbarFixedClass)).toBeFalsy(); + expect(controller.fixed).toBeFalsy(); + }); + + it("should focus element when menu is opened", () => { + const name = mockData.asideLinks[0].name; + component = testUtils.compileTemplate('', { + asideLinks: mockData.asideLinks + }); + controller = component.controller("ouiNavbar"); + + const button = controller.keyboardNav[name][0]; + spyOn(button, "focus"); + controller.toggleMenu(name); + + $timeout.flush(); + expect(button.focus).toHaveBeenCalled(); + }); + + it("should have mainLinks equal togglerLinks", () => { + component = testUtils.compileTemplate('', { + mainLinks: mockData.mainLinks + }); + controller = component.controller("ouiNavbar"); + + expect(controller.mainLinks).toEqual(controller.togglerLinks); + }); + }); describe("Brand", () => { const data = mockData.brand; - let component; let brand; + let component; + let controller; beforeEach(() => { - component = testUtils.compileTemplate('', { + component = testUtils.compileTemplate(` + + + + `, { brand: data }); + controller = component.find("oui-navbar-brand").controller("ouiNavbarBrand"); + + $timeout.flush(); + brand = angular.element(component[0].querySelector(".oui-navbar__brand")); }); + it("should remove aria-label", () => { + expect(controller.$element.attr("aria-label")).toBeUndefined(); + }); + it("should create a link", () => { expect(brand.length).toEqual(1); expect(brand[0].tagName).toBe("A"); @@ -42,6 +268,181 @@ describe("ouiNavbar", () => { }); }); + describe("Dropdown", () => { + let component; + + beforeEach(() => { + component = testUtils.compileTemplate(` + + + + + + + + `, { + name: "foo", + title: "bar", + label: "lorem", + badge: 5, + icon: "oui-icon oui-icon-help_circle", + text: "Lorem ipsum" + }); + + $timeout.flush(); + }); + + it("should have default classname", () => { + const dropdown = component.find("oui-navbar-dropdown"); + const dropdownMenu = component.find("oui-navbar-dropdown-menu"); + + expect(dropdown.hasClass(navbarDropdownClass)).toBeTruthy(); + expect(dropdown.hasClass(navbarListItemClass)).toBeTruthy(); + + expect(dropdownMenu.hasClass(navbarMenuClass)).toBeTruthy(); + expect(dropdownMenu.hasClass(navbarMenuFixedClass)).toBeTruthy(); + }); + + it("should have content transcluded", () => { + const dropdownMenu = component.find("oui-navbar-dropdown-menu"); + + expect(dropdownMenu.text()).toBe("Lorem ipsum"); + }); + + it("should have alignment 'end'", () => { + component = testUtils.compileTemplate(` + + + + + + + + `, { + name: "foo", + title: "bar", + label: "lorem", + badge: 5, + icon: "oui-icon oui-icon-help_circle", + text: "Lorem ipsum" + }); + const dropdownMenu = component.find("oui-navbar-dropdown-menu"); + + $timeout.flush(); + + expect(dropdownMenu.hasClass(navbarMenuEndClass)).toBeTruthy(); + }); + }); + + describe("Menu", () => { + const data = mockData.asideLinks[0]; + + let component; + let menu; + + beforeEach(() => { + component = testUtils.compileTemplate(` + + + + + + + + `, { + name: data.name, + title: data.title, + headerTitle: data.headerTitle, + subLinks: data.subLinks + }); + + $timeout.flush(); + + menu = component.find("oui-navbar-menu"); + }); + + it("should have default classname", () => { + expect(menu.hasClass(navbarMenuClass)).toBeTruthy(); + expect(menu.hasClass(navbarMenuFixedClass)).toBeFalsy(); + expect(menu.hasClass(navbarMenuEndClass)).toBeFalsy(); + }); + + it("should have role 'menu'", () => { + expect(menu.attr("role")).toBe("menu"); + }); + + it("should have alignment", () => { + component = testUtils.compileTemplate(` + + + + + + + + `, { + name: data.name, + title: data.title, + headerTitle: data.headerTitle, + subLinks: data.subLinks + }); + + $timeout.flush(); + + menu = component.find("oui-navbar-menu"); + expect(menu.hasClass(navbarMenuEndClass)).toBeTruthy(); + }); + + it("should be fixed", () => { + component = testUtils.compileTemplate(` + + + + + + + + `, { + name: data.name, + title: data.title, + headerTitle: data.headerTitle, + subLinks: data.subLinks + }); + + $timeout.flush(); + + menu = component.find("oui-navbar-menu"); + expect(menu.hasClass(navbarMenuFixedClass)).toBeTruthy(); + }); + }); + describe("Main links", () => { const data = mockData.mainLinks; const activeIndex = 0; @@ -55,6 +456,8 @@ describe("ouiNavbar", () => { mainLinks: data }); + $timeout.flush(); + menu = angular.element(component[0].querySelector(".oui-navbar-list_main")); links = menu.children("li"); }); @@ -98,6 +501,9 @@ describe("ouiNavbar", () => { component = testUtils.compileTemplate('', { asideLinks: data }); + + $timeout.flush(); + menu = angular.element(component[0].querySelector(".oui-navbar-list_aside")); links = menu.children("li"); }); @@ -154,6 +560,8 @@ describe("ouiNavbar", () => { togglerLinks: data }); + $timeout.flush(); + toggler = angular.element(component[0].querySelector(".oui-navbar-toggler")); responsiveMenu = angular.element(component[0].querySelector(".oui-navbar-menu_toggle")); }); @@ -185,6 +593,8 @@ describe("ouiNavbar", () => { mainLinks: data }); + $timeout.flush(); + backdrop = angular.element(component[0].querySelector(".oui-navbar-backdrop")); toggler = angular.element(component[0].querySelector(".oui-navbar-toggler")); }); diff --git a/packages/oui-navbar/src/keyboard-keys.constant.js b/packages/oui-navbar/src/keyboard-keys.constant.js index 7a757d5e..88461f3f 100644 --- a/packages/oui-navbar/src/keyboard-keys.constant.js +++ b/packages/oui-navbar/src/keyboard-keys.constant.js @@ -1,5 +1,5 @@ export default { - ALT: 9, - TAB: 16, + TAB: 9, + SHIFT: 16, ESC: 27 }; diff --git a/packages/oui-navbar/src/link/navbar-link.component.js b/packages/oui-navbar/src/link/navbar-link.component.js new file mode 100644 index 00000000..65f2e78f --- /dev/null +++ b/packages/oui-navbar/src/link/navbar-link.component.js @@ -0,0 +1,19 @@ +import controller from "./navbar-link.controller"; +import template from "./navbar-link.html"; + +export default { + require: { + navbarCtrl: "^^ouiNavbar" + }, + bindings: { + name: "@", + text: "@", + href: "@?", + state: "@?", + stateParams: " + this.$element + .addClass("oui-navbar-list__item") + ); + } + + // Return value of "ui-sref" + getFullSref () { + return `${this.state}(${JSON.stringify(this.stateParams)})`; + } +} diff --git a/packages/oui-navbar/src/link/navbar-link.html b/packages/oui-navbar/src/link/navbar-link.html new file mode 100644 index 00000000..2c1e4fae --- /dev/null +++ b/packages/oui-navbar/src/link/navbar-link.html @@ -0,0 +1,27 @@ + + + + diff --git a/packages/oui-navbar/src/menu/navbar-menu.component.js b/packages/oui-navbar/src/menu/navbar-menu.component.js index d2e25b66..6cba4db1 100644 --- a/packages/oui-navbar/src/menu/navbar-menu.component.js +++ b/packages/oui-navbar/src/menu/navbar-menu.component.js @@ -2,17 +2,18 @@ import controller from "./navbar-menu.controller"; import template from "./navbar-menu.html"; export default { + require: { + navbarCtrl: "^^ouiNavbar" + }, bindings: { backButton: "

-