diff --git a/spec/unit/combo-box/combo-box-change-event.spec.js b/spec/unit/combo-box/combo-box-change-event.spec.js index fd5a009dde..b9cdb4c646 100755 --- a/spec/unit/combo-box/combo-box-change-event.spec.js +++ b/spec/unit/combo-box/combo-box-change-event.spec.js @@ -3,117 +3,12 @@ const path = require("path"); const sinon = require("sinon"); const assert = require("assert"); const ComboBox = require("../../../src/js/components/combo-box"); +const EVENTS = require("./events"); const TEMPLATE = fs.readFileSync( path.join(__dirname, "/combo-box-change-event.template.html") ); -const EVENTS = {}; - -/** - * send a click event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.click = el => { - const evt = new MouseEvent("click", { - view: el.ownerDocument.defaultView, - bubbles: true, - cancelable: true - }); - el.dispatchEvent(evt); -}; - -/** - * send a focusout event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.focusout = el => { - const evt = new Event("focusout", { - bubbles: true, - cancelable: true - }); - el.dispatchEvent(evt); -}; - -/** - * send a keyup A event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keyupA = el => { - const evt = new KeyboardEvent("keyup", { - bubbles: true, - key: "a", - keyCode: 65 - }); - el.dispatchEvent(evt); -}; - -/** - * send a keyup O event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keyupO = el => { - const evt = new KeyboardEvent("keyup", { - bubbles: true, - key: "o", - keyCode: 79 - }); - el.dispatchEvent(evt); -}; - -/** - * send a keydown Enter event - * @param {HTMLElement} el the element to sent the event to - * @returns {{preventDefaultSpy: sinon.SinonSpy<[], void>}} - */ -EVENTS.keydownEnter = el => { - const evt = new KeyboardEvent("keydown", { - bubbles: true, - key: "Enter", - keyCode: 13 - }); - const preventDefaultSpy = sinon.spy(evt, "preventDefault"); - el.dispatchEvent(evt); - return { preventDefaultSpy }; -}; - -/** - * send a keydown Escape event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keydownEscape = el => { - const evt = new KeyboardEvent("keydown", { - bubbles: true, - key: "Escape", - keyCode: 27 - }); - el.dispatchEvent(evt); -}; - -/** - * send a keydown ArrowDown event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keydownArrowDown = el => { - const evt = new KeyboardEvent("keydown", { - bubbles: true, - key: "ArrowDown" - }); - el.dispatchEvent(evt); -}; - -/** - * send a keydown Tab event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keydownTab = el => { - const evt = new KeyboardEvent("keydown", { - bubbles: true, - key: "Tab" - }); - el.dispatchEvent(evt); -}; - describe("combo box component change event dispatch", () => { const { body } = document; @@ -178,86 +73,56 @@ describe("combo box component change event dispatch", () => { ); }); - it("should emit change events when clearing input values when an incomplete item is remaining on blur", () => { + it("should emit change events when resetting input values when an incomplete item is submitted through enter", () => { select.value = "value-ActionScript"; input.value = "a"; - EVENTS.keyupA(input); - assert.ok(!list.hidden, "should display the option list"); - - EVENTS.keydownTab(input); - EVENTS.focusout(input); - - assert.equal(select.value, "", "should clear the value on the select"); - assert.equal(input.value, "", "should clear the value on the input"); - assert.ok(selectChangeSpy.called, "should have dispatched a change event"); - assert.ok(inputChangeSpy.called, "should have dispatched a change event"); - }); - - it("should emit change events when clearing input values when an incomplete item is submitted through enter", () => { - select.value = "value-ActionScript"; - input.value = "a"; - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); EVENTS.keydownEnter(input); - assert.equal(select.value, "", "should clear the value on the select"); - assert.equal(input.value, "", "should clear the value on the input"); - assert.ok(selectChangeSpy.called, "should have dispatched a change event"); - assert.ok(inputChangeSpy.called, "should have dispatched a change event"); - }); - - it("should not emit change events when closing the list but not the clear the input value when escape is performed while the list is open", () => { - select.value = "value-ActionScript"; - input.value = "a"; - EVENTS.keyupA(input); - assert.ok(!list.hidden, "should display the option list"); - - EVENTS.keydownEscape(input); - + assert.equal(select.value, "value-ActionScript"); assert.equal( - select.value, - "value-ActionScript", - "should not change the value of the select" + input.value, + "ActionScript", + "should reset the value on the input" ); - assert.equal(input.value, "a", "should not change the value in the input"); assert.ok( selectChangeSpy.notCalled, "should not have dispatched a change event" ); - assert.ok( - inputChangeSpy.notCalled, - "should not have dispatched a change event" - ); + assert.ok(inputChangeSpy.called, "should have dispatched a change event"); }); - it("should emit change events when setting the input value when a complete selection is submitted by clicking away", () => { + it("should emit change events when closing the list but not the clear the input value when escape is performed while the list is open", () => { select.value = "value-ActionScript"; - input.value = "go"; - EVENTS.keyupO(input); + input.value = "a"; + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); - EVENTS.keydownTab(input); - EVENTS.focusout(input); + EVENTS.keydownEscape(input); assert.equal( select.value, - "value-Go", - "should set that item to being the select option" + "value-ActionScript", + "should not change the value of the select" ); assert.equal( input.value, - "Go", - "should set that item to being the input value" + "ActionScript", + "should reset the value in the input" + ); + assert.ok( + selectChangeSpy.notCalled, + "should not have dispatched a change event" ); - assert.ok(selectChangeSpy.called, "should have dispatched a change event"); assert.ok(inputChangeSpy.called, "should have dispatched a change event"); }); it("should emit change events when setting the input value when a complete selection is submitted by pressing enter", () => { select.value = "value-ActionScript"; input.value = "go"; - EVENTS.keyupO(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); EVENTS.keydownEnter(input); @@ -280,7 +145,7 @@ describe("combo box component change event dispatch", () => { select.value = "value-JavaScript"; input.value = "la"; - EVENTS.keyupA(input); + EVENTS.input(input); EVENTS.keydownArrowDown(input); const focusedOption = document.activeElement; assert.equal( @@ -300,11 +165,11 @@ describe("combo box component change event dispatch", () => { assert.ok(inputChangeSpy.called, "should have dispatched a change event"); }); - it("should not emit change events when pressing escape from a focused item", () => { + it("should emit change events when pressing escape from a focused item", () => { select.value = "value-JavaScript"; input.value = "la"; - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok( !list.hidden && list.children.length, "should display the option list with options" @@ -319,20 +184,20 @@ describe("combo box component change event dispatch", () => { EVENTS.keydownEscape(focusedOption); assert.ok(list.hidden, "should hide the option list"); - assert.equal(list.children.length, 0, "should empty the option list"); assert.equal( select.value, "value-JavaScript", "should not change the value of the select" ); - assert.equal(input.value, "la", "should not change the value in the input"); - assert.ok( - selectChangeSpy.notCalled, - "should not have dispatched a change event" + assert.equal( + input.value, + "JavaScript", + "should reset the value in the input" ); assert.ok( - inputChangeSpy.notCalled, + selectChangeSpy.notCalled, "should not have dispatched a change event" ); + assert.ok(inputChangeSpy.called, "should have dispatched a change event"); }); }); diff --git a/spec/unit/combo-box/combo-box-change-event.template.html b/spec/unit/combo-box/combo-box-change-event.template.html index 36baf7a480..9673a8a69e 100755 --- a/spec/unit/combo-box/combo-box-change-event.template.html +++ b/spec/unit/combo-box/combo-box-change-event.template.html @@ -3,12 +3,7 @@ >Your preferred programming language:
- diff --git a/spec/unit/combo-box/combo-box-default-value.template.html b/spec/unit/combo-box/combo-box-default-value.template.html index c47eddd71c..f837cd60ee 100755 --- a/spec/unit/combo-box/combo-box-default-value.template.html +++ b/spec/unit/combo-box/combo-box-default-value.template.html @@ -3,11 +3,7 @@ >Your preferred programming language:
- diff --git a/spec/unit/combo-box/combo-box-disabled.spec.js b/spec/unit/combo-box/combo-box-disabled.spec.js index 2c05f197de..84b005449f 100755 --- a/spec/unit/combo-box/combo-box-disabled.spec.js +++ b/spec/unit/combo-box/combo-box-disabled.spec.js @@ -28,6 +28,7 @@ describe("combo box component - disabled enhancement", () => { let root; let input; let select; + let toggle; let list; beforeEach(() => { @@ -36,6 +37,7 @@ describe("combo box component - disabled enhancement", () => { root = body.querySelector(".usa-combo-box"); input = root.querySelector(".usa-combo-box__input"); select = root.querySelector(".usa-combo-box__select"); + toggle = root.querySelector(".usa-combo-box__toggle-list"); list = root.querySelector(".usa-combo-box__list"); }); @@ -63,4 +65,17 @@ describe("combo box component - disabled enhancement", () => { assert.ok(list.hidden, "should not display the option list"); }); + + it("should not show the list when clicking the disabled button", () => { + EVENTS.click(toggle); + + assert.ok(list.hidden, "should not display the option list"); + }); + + it("should show the list when clicking the input once the component has been enabled", () => { + ComboBox.enable(root); + EVENTS.click(input); + + assert.ok(!list.hidden, "should display the option list"); + }); }); diff --git a/spec/unit/combo-box/combo-box-disabled.template.html b/spec/unit/combo-box/combo-box-disabled.template.html index a68407b30a..36fe139073 100755 --- a/spec/unit/combo-box/combo-box-disabled.template.html +++ b/spec/unit/combo-box/combo-box-disabled.template.html @@ -1,5 +1,6 @@
-
- diff --git a/spec/unit/combo-box/combo-box-subsequent-selection.spec.js b/spec/unit/combo-box/combo-box-subsequent-selection.spec.js new file mode 100755 index 0000000000..b20385ecb8 --- /dev/null +++ b/spec/unit/combo-box/combo-box-subsequent-selection.spec.js @@ -0,0 +1,127 @@ +const fs = require("fs"); +const path = require("path"); +const assert = require("assert"); +const ComboBox = require("../../../src/js/components/combo-box"); +const EVENTS = require("./events"); + +const TEMPLATE = fs.readFileSync( + path.join(__dirname, "/combo-box-subsequent-selection.template.html") +); + +describe("combo box component - subsequent selection", () => { + const { body } = document; + + let root; + let input; + let select; + let list; + + beforeEach(() => { + body.innerHTML = TEMPLATE; + ComboBox.on(); + root = body.querySelector(".usa-combo-box"); + input = root.querySelector(".usa-combo-box__input"); + select = root.querySelector(".usa-combo-box__select"); + list = root.querySelector(".usa-combo-box__list"); + }); + + afterEach(() => { + body.textContent = ""; + ComboBox.off(body); + }); + + it("should display the full list and focus the selected item when the input is pristine (after fresh selection)", () => { + assert.ok( + root.classList.contains("usa-combo-box--pristine"), + "pristine class added after selection" + ); + EVENTS.click(input); + + assert.ok(!list.hidden, "should show the option list"); + assert.equal( + list.children.length, + select.options.length - 1, + "should have all of the initial select items in the list except placeholder empty items" + ); + const highlightedOption = list.querySelector( + ".usa-combo-box__list-option--focused" + ); + assert.ok( + highlightedOption.classList.contains( + "usa-combo-box__list-option--focused" + ), + "should style the focused item in the list" + ); + assert.equal( + highlightedOption.textContent, + "Go", + "should be the previously selected item" + ); + }); + + it("should display the filtered list when the input is dirty (characters inputted)", () => { + EVENTS.click(input); + assert.equal( + list.children.length, + select.options.length - 1, + "should have all of the initial select items in the list except placeholder empty items" + ); + + input.value = "COBOL"; + EVENTS.input(input); + + assert.ok( + !root.classList.contains("usa-combo-box--pristine"), + "pristine class is removed after input" + ); + assert.equal( + list.children.length, + 1, + "should only show the filtered items" + ); + }); + + it("should show a clear button when the input has a selected value present", () => { + assert.ok( + root.classList.contains("usa-combo-box--pristine"), + "pristine class added after selection" + ); + assert.ok( + root.querySelector(".usa-combo-box__clear-input"), + "clear input button is present" + ); + }); + + it("should clear the input when the clear button is clicked", () => { + assert.equal(select.value, "value-Go"); + assert.equal(input.value, "Go"); + + EVENTS.click(root.querySelector(".usa-combo-box__clear-input")); + + assert.equal(select.value, "", "should clear the value on the select"); + assert.equal(input.value, "", "should clear the value on the input"); + assert.equal(document.activeElement, input, "should focus the input"); + }); + + it("should update the filter and begin filtering once a pristine input value is changed", () => { + input.value = "go"; + EVENTS.click(input); + EVENTS.keydownEnter(input); + assert.equal(input.value, "Go", "should set that item to the input value"); + EVENTS.click(input); + assert.equal( + list.children.length, + select.options.length - 1, + "should have all of the initial select items in the list except placeholder empty items" + ); + + input.value = "COBOL"; + EVENTS.input(input); + + assert.equal( + list.children.length, + 1, + "should only show the filtered items" + ); + }); +}); diff --git a/spec/unit/combo-box/combo-box-subsequent-selection.template.html b/spec/unit/combo-box/combo-box-subsequent-selection.template.html new file mode 100755 index 0000000000..580e536541 --- /dev/null +++ b/spec/unit/combo-box/combo-box-subsequent-selection.template.html @@ -0,0 +1,33 @@ +
+ +
+ +
+
diff --git a/spec/unit/combo-box/combo-box.spec.js b/spec/unit/combo-box/combo-box.spec.js index 0b9e987e51..9e1391902b 100755 --- a/spec/unit/combo-box/combo-box.spec.js +++ b/spec/unit/combo-box/combo-box.spec.js @@ -1,128 +1,12 @@ const fs = require("fs"); const path = require("path"); -const sinon = require("sinon"); const assert = require("assert"); const ComboBox = require("../../../src/js/components/combo-box"); +const EVENTS = require("./events"); -const TEMPLATE = fs.readFileSync(path.join(__dirname, "/template.html")); - -const EVENTS = {}; - -/** - * send a click event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.click = el => { - const evt = new MouseEvent("click", { - view: el.ownerDocument.defaultView, - bubbles: true, - cancelable: true - }); - el.dispatchEvent(evt); -}; - -/** - * send a focusout event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.focusout = el => { - const evt = new Event("focusout", { - bubbles: true, - cancelable: true - }); - el.dispatchEvent(evt); -}; - -/** - * send a keyup A event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keyupA = el => { - const evt = new KeyboardEvent("keyup", { - bubbles: true, - key: "a", - keyCode: 65 - }); - el.dispatchEvent(evt); -}; - -/** - * send a keyup O event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keyupO = el => { - const evt = new KeyboardEvent("keyup", { - bubbles: true, - key: "o", - keyCode: 79 - }); - el.dispatchEvent(evt); -}; - -/** - * send a keydown Enter event - * @param {HTMLElement} el the element to sent the event to - * @returns {{preventDefaultSpy: sinon.SinonSpy<[], void>}} - */ -EVENTS.keydownEnter = el => { - const evt = new KeyboardEvent("keydown", { - bubbles: true, - key: "Enter", - keyCode: 13 - }); - const preventDefaultSpy = sinon.spy(evt, "preventDefault"); - el.dispatchEvent(evt); - return { preventDefaultSpy }; -}; - -/** - * send a keydown Escape event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keydownEscape = el => { - const evt = new KeyboardEvent("keydown", { - bubbles: true, - key: "Escape", - keyCode: 27 - }); - el.dispatchEvent(evt); -}; - -/** - * send a keydown ArrowDown event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keydownArrowDown = el => { - const evt = new KeyboardEvent("keydown", { - bubbles: true, - key: "ArrowDown" - }); - el.dispatchEvent(evt); -}; - -/** - * send a keydown ArrowUp event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keydownArrowUp = el => { - const evt = new KeyboardEvent("keydown", { - bubbles: true, - key: "ArrowUp" - }); - el.dispatchEvent(evt); -}; - -/** - * send a keydown Tab event - * @param {HTMLElement} el the element to sent the event to - */ -EVENTS.keydownTab = el => { - const evt = new KeyboardEvent("keydown", { - bubbles: true, - key: "Tab" - }); - el.dispatchEvent(evt); -}; +const TEMPLATE = fs.readFileSync( + path.join(__dirname, "/combo-box.template.html") +); describe("combo box component", () => { const { body } = document; @@ -131,12 +15,14 @@ describe("combo box component", () => { let input; let select; let list; + let toggle; beforeEach(() => { body.innerHTML = TEMPLATE; ComboBox.on(); root = body.querySelector(".usa-combo-box"); input = root.querySelector(".usa-combo-box__input"); + toggle = root.querySelector(".usa-combo-box__toggle-list"); select = root.querySelector(".usa-combo-box__select"); list = root.querySelector(".usa-combo-box__list"); }); @@ -207,8 +93,6 @@ describe("combo box component", () => { }); it("should show the list by clicking the input", () => { - input.value = ""; - EVENTS.click(input); assert.ok(!list.hidden, "should display the option list"); @@ -219,6 +103,12 @@ describe("combo box component", () => { ); }); + it("should show the list by clicking the toggle button", () => { + EVENTS.click(toggle); + + assert.ok(!list.hidden, "should display the option list"); + }); + it("should show the list by clicking when clicking the input twice", () => { input.value = ""; @@ -226,17 +116,37 @@ describe("combo box component", () => { EVENTS.click(input); assert.ok(!list.hidden, "should keep the option list displayed"); - assert.equal( - list.children.length, - select.options.length - 1, - "should have all of the initial select items in the list except placeholder empty items" - ); + }); + + it("should toggle the list and close by clicking when clicking the toggle button twice", () => { + EVENTS.click(toggle); + EVENTS.click(toggle); + + assert.ok(list.hidden, "should hide the option list"); }); it("should set up the list items for accessibility", () => { + let i = 0; + let len = list.children.length; EVENTS.click(input); - for (let i = 0, len = list.children.length; i < len; i += 1) { + assert.equal( + list.children[i].getAttribute("aria-selected"), + "false", + `item ${i} should not be shown as selected` + ); + assert.equal( + list.children[i].getAttribute("tabindex"), + "0", + `item ${i} should be available with tab from keyboard navigation` + ); + assert.equal( + list.children[i].getAttribute("role"), + "option", + `item ${i} should have a role of 'option'` + ); + + for (i = 1; i < len; i += 1) { assert.equal( list.children[i].getAttribute("aria-selected"), "false", @@ -259,7 +169,6 @@ describe("combo box component", () => { EVENTS.click(input); EVENTS.focusout(input); - assert.equal(list.children.length, 0, "should empty the option list"); assert.ok(list.hidden, "should hide the option list"); }); @@ -272,21 +181,20 @@ describe("combo box component", () => { assert.equal( select.value, "value-ActionScript", - "should set that item to being the select option" + "should set that item to the select option" ); assert.equal( input.value, "ActionScript", - "should set that item to being the input value" + "should set that item to the input value" ); assert.ok(list.hidden, "should hide the option list"); - assert.equal(list.children.length, 0, "should empty the option list"); }); it("should display and filter the option list after a character is typed", () => { input.value = "a"; - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); assert.equal( @@ -296,116 +204,105 @@ describe("combo box component", () => { ); }); - it("should clear input values when an incomplete item is remaining on tab/blur", () => { + it("should reset input values when an incomplete item is remaining on blur", () => { select.value = "value-ActionScript"; input.value = "a"; - - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); - EVENTS.keydownTab(input); + EVENTS.focusout(input); assert.ok(list.hidden, "should hide the option list"); - assert.equal(list.children.length, 0, "should empty the option list"); - assert.equal(select.value, "", "should clear the value on the select"); - assert.equal(input.value, "", "should clear the value on the input"); + assert.equal(select.value, "value-ActionScript"); + assert.equal(input.value, "ActionScript"); }); - it("should clear input values when an incomplete item is submitted through enter", () => { + it("should reset input values when an incomplete item is submitted through enter", () => { select.value = "value-ActionScript"; input.value = "a"; - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); const { preventDefaultSpy } = EVENTS.keydownEnter(input); assert.ok(list.hidden, "should hide the option list"); - assert.equal(list.children.length, 0, "should empty the option list"); - assert.equal(select.value, "", "should clear the value on the select"); - assert.equal(input.value, "", "should clear the value on the input"); + assert.equal(select.value, "value-ActionScript"); + assert.equal( + input.value, + "ActionScript", + "should reset the value on the input" + ); assert.ok( preventDefaultSpy.called, "should not have allowed enter to perform default action" ); }); - it("should allow enter to perform default action when the list is hidden", () => { + it("should not allow enter to perform default action when the list is hidden", () => { assert.ok(list.hidden, "the list is hidden"); const { preventDefaultSpy } = EVENTS.keydownEnter(input); assert.ok( - preventDefaultSpy.notCalled, - "should allow event to perform default action" + preventDefaultSpy.called, + "should not allow event to perform default action" ); }); - it("should close the list but not the clear the input value when escape is performed while the list is open", () => { + it("should close the list and reset input value when escape is performed while the list is open", () => { select.value = "value-ActionScript"; input.value = "a"; - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); EVENTS.keydownEscape(input); assert.ok(list.hidden, "should hide the option list"); - assert.equal(list.children.length, 0, "should empty the option list"); assert.equal( select.value, "value-ActionScript", "should not change the value of the select" ); - assert.equal(input.value, "a", "should not change the value in the input"); + assert.equal( + input.value, + "ActionScript", + "should not change the value in the input" + ); }); - it("should set the input value when a complete selection is left on tab/blur from the input element", () => { + it("should reset the input value when a complete selection is left on blur from the input element", () => { select.value = "value-ActionScript"; input.value = "go"; - - EVENTS.keyupO(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); - EVENTS.keydownTab(input); + EVENTS.focusout(input); assert.ok(list.hidden, "should hide the option list"); - assert.equal(list.children.length, 0, "should empty the option list"); - assert.equal( - select.value, - "value-Go", - "should set that item to being the select option" - ); - assert.equal( - input.value, - "Go", - "should set that item to being the input value" - ); + assert.equal(select.value, "value-ActionScript"); + assert.equal(input.value, "ActionScript"); }); it("should set the input value when a complete selection is submitted by pressing enter", () => { select.value = "value-ActionScript"; input.value = "go"; - EVENTS.keyupO(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); EVENTS.keydownEnter(input); assert.ok(list.hidden, "should hide the option list"); - assert.equal(list.children.length, 0, "should empty the option list"); assert.equal( select.value, "value-Go", - "should set that item to being the select option" - ); - assert.equal( - input.value, - "Go", - "should set that item to being the input value" + "should set that item to the select option" ); + assert.equal(input.value, "Go", "should set that item to the input value"); }); it("should show the no results item when a nonexistent option is typed", () => { input.value = "Bibbidi-Bobbidi-Boo"; - EVENTS.keyupO(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); assert.equal(list.children.length, 1, "should show no results list item"); @@ -426,7 +323,7 @@ describe("combo box component", () => { it("should focus the first item in the list when pressing down from the input", () => { input.value = "la"; - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); assert.equal( list.children.length, @@ -450,8 +347,7 @@ describe("combo box component", () => { it("should select the focused list item in the list when pressing enter on a focused item", () => { select.value = "value-JavaScript"; input.value = "la"; - - EVENTS.keyupA(input); + EVENTS.input(input); EVENTS.keydownArrowDown(input); const focusedOption = document.activeElement; assert.equal( @@ -459,6 +355,7 @@ describe("combo box component", () => { "Erlang", "should focus the first item in the list" ); + EVENTS.keydownEnter(focusedOption); assert.equal( @@ -469,10 +366,49 @@ describe("combo box component", () => { assert.equal(input.value, "Erlang", "should set the value in the input"); }); + it("should select the focused list item in the list when pressing tab on a focused item", () => { + select.value = "value-JavaScript"; + input.value = "la"; + EVENTS.input(input); + EVENTS.keydownArrowDown(input); + const focusedOption = document.activeElement; + assert.equal( + focusedOption.textContent, + "Erlang", + "should focus the first item in the list" + ); + + EVENTS.keydownTab(focusedOption); + + assert.equal( + select.value, + "value-Erlang", + "select the first item in the list" + ); + assert.equal(input.value, "Erlang", "should set the value in the input"); + }); + + it("should not select the focused list item in the list when blurring component from a focused item", () => { + input.value = "la"; + EVENTS.input(input); + EVENTS.keydownArrowDown(input); + const focusedOption = document.activeElement; + assert.equal( + focusedOption.textContent, + "Erlang", + "should focus the first item in the list" + ); + + EVENTS.focusout(focusedOption); + + assert.equal(select.value, ""); + assert.equal(input.value, "", "should reset the value in the input"); + }); + it("should focus the last item in the list when pressing down many times from the input", () => { input.value = "la"; - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); assert.equal( list.children.length, @@ -499,7 +435,7 @@ describe("combo box component", () => { select.value = "value-JavaScript"; input.value = "la"; - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok( !list.hidden && list.children.length, "should display the option list with options" @@ -514,19 +450,22 @@ describe("combo box component", () => { EVENTS.keydownEscape(focusedOption); assert.ok(list.hidden, "should hide the option list"); - assert.equal(list.children.length, 0, "should empty the option list"); assert.equal( select.value, "value-JavaScript", "should not change the value of the select" ); - assert.equal(input.value, "la", "should not change the value in the input"); + assert.equal( + input.value, + "JavaScript", + "should reset the value in the input" + ); }); it("should focus the input and hide the list when pressing up from the first item in the list", () => { input.value = "la"; - EVENTS.keyupA(input); + EVENTS.input(input); assert.ok(!list.hidden, "should display the option list"); assert.equal( list.children.length, diff --git a/spec/unit/combo-box/template.html b/spec/unit/combo-box/combo-box.template.html similarity index 88% rename from spec/unit/combo-box/template.html rename to spec/unit/combo-box/combo-box.template.html index 76b6adacd8..27943dbe33 100755 --- a/spec/unit/combo-box/template.html +++ b/spec/unit/combo-box/combo-box.template.html @@ -1,5 +1,7 @@
- +
-
\ No newline at end of file + diff --git a/spec/unit/combo-box/events.js b/spec/unit/combo-box/events.js new file mode 100644 index 0000000000..3f94d506ea --- /dev/null +++ b/spec/unit/combo-box/events.js @@ -0,0 +1,103 @@ +const sinon = require("sinon"); + +const EVENTS = {}; + +/** + * send a click event + * @param {HTMLElement} el the element to sent the event to + */ +EVENTS.click = el => { + const evt = new MouseEvent("click", { + view: el.ownerDocument.defaultView, + bubbles: true, + cancelable: true + }); + el.dispatchEvent(evt); +}; + +/** + * send a focusout event + * @param {HTMLElement} el the element to sent the event to + */ +EVENTS.focusout = el => { + const evt = new Event("focusout", { + bubbles: true, + cancelable: true + }); + el.dispatchEvent(evt); +}; + +/** + * send a keydown Enter event + * @param {HTMLElement} el the element to sent the event to + * @returns {{preventDefaultSpy: sinon.SinonSpy<[], void>}} + */ +EVENTS.keydownEnter = el => { + const evt = new KeyboardEvent("keydown", { + bubbles: true, + key: "Enter", + keyCode: 13 + }); + const preventDefaultSpy = sinon.spy(evt, "preventDefault"); + el.dispatchEvent(evt); + return { preventDefaultSpy }; +}; + +/** + * send a keydown Escape event + * @param {HTMLElement} el the element to sent the event to + */ +EVENTS.keydownEscape = el => { + const evt = new KeyboardEvent("keydown", { + bubbles: true, + key: "Escape", + keyCode: 27 + }); + el.dispatchEvent(evt); +}; + +/** + * send a keydown Tab event + * @param {HTMLElement} el the element to sent the event to + */ +EVENTS.keydownTab = el => { + const evt = new KeyboardEvent("keydown", { + bubbles: true, + key: "Tab" + }); + el.dispatchEvent(evt); +}; + +/** + * send a keydown ArrowDown event + * @param {HTMLElement} el the element to sent the event to + */ +EVENTS.keydownArrowDown = el => { + const evt = new KeyboardEvent("keydown", { + bubbles: true, + key: "ArrowDown" + }); + el.dispatchEvent(evt); +}; + +/** + * send a keydown ArrowUp event + * @param {HTMLElement} el the element to sent the event to + */ +EVENTS.keydownArrowUp = el => { + const evt = new KeyboardEvent("keydown", { + bubbles: true, + key: "ArrowUp" + }); + el.dispatchEvent(evt); +}; + +/** + * send an input event + * @param {HTMLElement} el the element to sent the event to + */ +EVENTS.input = el => { + el.dispatchEvent(new KeyboardEvent("input", { bubbles: true })); +}; + +module.exports = EVENTS; diff --git a/src/img/arrow-down-gray-60.svg b/src/img/arrow-down-gray-60.svg new file mode 100644 index 0000000000..05b18e152f --- /dev/null +++ b/src/img/arrow-down-gray-60.svg @@ -0,0 +1 @@ +arrow-down \ No newline at end of file diff --git a/src/img/close-gray-60.svg b/src/img/close-gray-60.svg new file mode 100644 index 0000000000..60d34e16d3 --- /dev/null +++ b/src/img/close-gray-60.svg @@ -0,0 +1 @@ +close \ No newline at end of file diff --git a/src/js/components/combo-box.js b/src/js/components/combo-box.js index 14c72265b8..ad530685fc 100644 --- a/src/js/components/combo-box.js +++ b/src/js/components/combo-box.js @@ -4,22 +4,34 @@ const behavior = require("../utils/behavior"); const { prefix: PREFIX } = require("../config"); const { CLICK } = require("../events"); -const COMBO_BOX = `.${PREFIX}-combo-box`; - -const INPUT_CLASS = `${PREFIX}-combo-box__input`; -const LIST_CLASS = `${PREFIX}-combo-box__list`; -const SELECT_CLASS = `${PREFIX}-combo-box__select`; -const LIST_OPTION_CLASS = `${PREFIX}-combo-box__list-option`; -const STATUS_CLASS = `${PREFIX}-combo-box__status`; +const COMBO_BOX_CLASS = `${PREFIX}-combo-box`; +const COMBO_BOX_PRISTINE_CLASS = `${COMBO_BOX_CLASS}--pristine`; +const SELECT_CLASS = `${COMBO_BOX_CLASS}__select`; +const INPUT_CLASS = `${COMBO_BOX_CLASS}__input`; +const CLEAR_INPUT_BUTTON_CLASS = `${COMBO_BOX_CLASS}__clear-input`; +const CLEAR_INPUT_BUTTON_WRAPPER_CLASS = `${CLEAR_INPUT_BUTTON_CLASS}__wrapper`; +const INPUT_BUTTON_SEPARATOR_CLASS = `${COMBO_BOX_CLASS}__input-button-separator`; +const TOGGLE_LIST_BUTTON_CLASS = `${COMBO_BOX_CLASS}__toggle-list`; +const TOGGLE_LIST_BUTTON_WRAPPER_CLASS = `${TOGGLE_LIST_BUTTON_CLASS}__wrapper`; +const LIST_CLASS = `${COMBO_BOX_CLASS}__list`; +const LIST_OPTION_CLASS = `${COMBO_BOX_CLASS}__list-option`; const LIST_OPTION_FOCUSED_CLASS = `${LIST_OPTION_CLASS}--focused`; +const LIST_OPTION_SELECTED_CLASS = `${LIST_OPTION_CLASS}--selected`; +const STATUS_CLASS = `${COMBO_BOX_CLASS}__status`; +const COMBO_BOX = `.${COMBO_BOX_CLASS}`; const SELECT = `.${SELECT_CLASS}`; const INPUT = `.${INPUT_CLASS}`; +const CLEAR_INPUT_BUTTON = `.${CLEAR_INPUT_BUTTON_CLASS}`; +const TOGGLE_LIST_BUTTON = `.${TOGGLE_LIST_BUTTON_CLASS}`; const LIST = `.${LIST_CLASS}`; const LIST_OPTION = `.${LIST_OPTION_CLASS}`; const LIST_OPTION_FOCUSED = `.${LIST_OPTION_FOCUSED_CLASS}`; +const LIST_OPTION_SELECTED = `.${LIST_OPTION_SELECTED_CLASS}`; const STATUS = `.${STATUS_CLASS}`; +const noop = () => {}; + /** * set the value of the element and dispatch a change event * @@ -38,33 +50,18 @@ const changeElementValue = (el, value = "") => { elementToChange.dispatchEvent(event); }; -/** - * Determine if the key code of an event is printable - * - * @param {number} keyCode The key code of the event - * @returns {boolean} true is the key code is printable - */ -const isPrintableKeyCode = keyCode => { - return ( - (keyCode > 47 && keyCode < 58) || // number keys - keyCode === 32 || // space - keyCode === 8 || // backspace - (keyCode > 64 && keyCode < 91) || // letter keys - (keyCode > 95 && keyCode < 112) || // numpad keys - (keyCode > 185 && keyCode < 193) || // ;=,-./` (in order) - (keyCode > 218 && keyCode < 223) // [\]' (in order) - ); -}; - /** * The elements within the combo box. - * @typedef {Object} ComboBoxElements + * @typedef {Object} ComboBoxContext * @property {HTMLElement} comboBoxEl * @property {HTMLSelectElement} selectEl * @property {HTMLInputElement} inputEl * @property {HTMLUListElement} listEl * @property {HTMLDivElement} statusEl * @property {HTMLLIElement} focusedOptionEl + * @property {HTMLLIElement} selectedOptionEl + * @property {HTMLButtonElement} toggleListBtnEl + * @property {HTMLButtonElement} clearInputBtnEl */ /** @@ -72,9 +69,9 @@ const isPrintableKeyCode = keyCode => { * combo box component. * * @param {HTMLElement} el the element within the combo box - * @returns {ComboBoxElements} elements + * @returns {ComboBoxContext} elements */ -const getComboBoxElements = el => { +const getComboBoxContext = el => { const comboBoxEl = el.closest(COMBO_BOX); if (!comboBoxEl) { @@ -86,8 +83,52 @@ const getComboBoxElements = el => { const listEl = comboBoxEl.querySelector(LIST); const statusEl = comboBoxEl.querySelector(STATUS); const focusedOptionEl = comboBoxEl.querySelector(LIST_OPTION_FOCUSED); + const selectedOptionEl = comboBoxEl.querySelector(LIST_OPTION_SELECTED); + const toggleListBtnEl = comboBoxEl.querySelector(TOGGLE_LIST_BUTTON); + const clearInputBtnEl = comboBoxEl.querySelector(CLEAR_INPUT_BUTTON); + + const isPristine = comboBoxEl.classList.contains(COMBO_BOX_PRISTINE_CLASS); + + return { + comboBoxEl, + selectEl, + inputEl, + listEl, + statusEl, + focusedOptionEl, + selectedOptionEl, + toggleListBtnEl, + clearInputBtnEl, + isPristine + }; +}; + +/** + * Disable the combo-box component + * + * @param {HTMLInputElement} el An element within the combo box component + */ +const disable = el => { + const { inputEl, toggleListBtnEl, clearInputBtnEl } = getComboBoxContext(el); + + clearInputBtnEl.hidden = true; + clearInputBtnEl.disabled = true; + toggleListBtnEl.disabled = true; + inputEl.disabled = true; +}; + +/** + * Enable the combo-box component + * + * @param {HTMLInputElement} el An element within the combo box component + */ +const enable = el => { + const { inputEl, toggleListBtnEl, clearInputBtnEl } = getComboBoxContext(el); - return { comboBoxEl, selectEl, inputEl, listEl, statusEl, focusedOptionEl }; + clearInputBtnEl.hidden = false; + clearInputBtnEl.disabled = false; + toggleListBtnEl.disabled = false; + inputEl.disabled = false; }; /** @@ -131,7 +172,7 @@ const enhanceComboBox = comboBoxEl => { selectEl.id = ""; selectEl.value = ""; - ["required", "disabled", "aria-label", "aria-labelledby"].forEach(name => { + ["required", "aria-label", "aria-labelledby"].forEach(name => { if (selectEl.hasAttribute(name)) { const value = selectEl.getAttribute(name); additionalAttributes.push(`${name}="${value}"`); @@ -155,6 +196,13 @@ const enhanceComboBox = comboBoxEl => { role="combobox" ${additionalAttributes.join(" ")} >`, + ` + + `, + ` `, + ` + + `, `
    { ); if (selectedOption) { - const { inputEl } = getComboBoxElements(comboBoxEl); + const { inputEl } = getComboBoxContext(comboBoxEl); changeElementValue(selectEl, selectedOption.value); changeElementValue(inputEl, selectedOption.text); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + } + + if (selectEl.disabled) { + disable(comboBoxEl); + selectEl.disabled = false; + } +}; + +/** + * Manage the focused element within the list options when + * navigating via keyboard. + * + * @param {HTMLElement} el An element within the combo box component + * @param {HTMLElement} currentEl An element within the combo box component + * @param {HTMLElement} nextEl An element within the combo box component + * @param {Object} options options + * @param {boolean} options.skipFocus skip focus of highlighted item + * @param {boolean} options.preventScroll should skip procedure to scroll to element + */ +const highlightOption = ( + el, + currentEl, + nextEl, + { skipFocus, preventScroll } = {} +) => { + const { inputEl, listEl, selectedOptionEl } = getComboBoxContext(el); + + if (selectedOptionEl) { + selectedOptionEl.tabIndex = "-1"; + } + + if (currentEl) { + currentEl.classList.remove(LIST_OPTION_FOCUSED_CLASS); + currentEl.setAttribute("aria-selected", "false"); + currentEl.setAttribute("tabIndex", "-1"); + } + + if (nextEl) { + inputEl.setAttribute("aria-activedescendant", nextEl.id); + nextEl.setAttribute("aria-selected", "true"); + nextEl.setAttribute("tabIndex", "0"); + nextEl.classList.add(LIST_OPTION_FOCUSED_CLASS); + + if (!preventScroll) { + const optionBottom = nextEl.offsetTop + nextEl.offsetHeight; + const currentBottom = listEl.scrollTop + listEl.offsetHeight; + + if (optionBottom > currentBottom) { + listEl.scrollTop = optionBottom - listEl.offsetHeight; + } + + if (nextEl.offsetTop < listEl.scrollTop) { + listEl.scrollTop = nextEl.offsetTop; + } + } + + if (!skipFocus) { + nextEl.focus({ preventScroll }); + } + } else { + inputEl.setAttribute("aria-activedescendant", ""); + inputEl.focus(); } }; @@ -183,7 +294,14 @@ const enhanceComboBox = comboBoxEl => { * @param {HTMLElement} el An element within the combo box component */ const displayList = el => { - const { selectEl, inputEl, listEl, statusEl } = getComboBoxElements(el); + const { + selectEl, + inputEl, + listEl, + statusEl, + isPristine + } = getComboBoxContext(el); + let selectedItemId; const listOptionBaseId = `${listEl.id}--option-`; @@ -194,27 +312,45 @@ const displayList = el => { const optionEl = selectEl.options[i]; if ( optionEl.value && - (!inputValue || optionEl.text.toLowerCase().indexOf(inputValue) !== -1) + (isPristine || + !inputValue || + optionEl.text.toLowerCase().indexOf(inputValue) !== -1) ) { + if (selectEl.value && optionEl.value === selectEl.value) { + selectedItemId = `${listOptionBaseId}${options.length}`; + } + options.push(optionEl); } } const numOptions = options.length; const optionHtml = options - .map( - (option, index) => - `
  • { + const optionId = `${listOptionBaseId}${index}`; + const classes = [LIST_OPTION_CLASS]; + let tabindex = "-1"; + + if (optionId === selectedItemId) { + classes.push(LIST_OPTION_SELECTED_CLASS); + tabindex = "0"; + } + + if (!selectedItemId && index === 0) { + tabindex = "0"; + } + + return `
  • ${option.text}
  • ` - ) + data-value="${option.value}" + >${option.text}`; + }) .join(""); const noResults = `
  • No results found
  • `; @@ -227,6 +363,13 @@ const displayList = el => { statusEl.innerHTML = numOptions ? `${numOptions} result${numOptions > 1 ? "s" : ""} available.` : "No results."; + + if (isPristine && selectedItemId) { + const selectedOptionEl = listEl.querySelector("#" + selectedItemId); + highlightOption(listEl, null, selectedOptionEl, { + skipFocus: true + }); + } }; /** @@ -235,15 +378,19 @@ const displayList = el => { * @param {HTMLElement} el An element within the combo box component */ const hideList = el => { - const { inputEl, listEl, statusEl } = getComboBoxElements(el); + const { inputEl, listEl, statusEl, focusedOptionEl } = getComboBoxContext(el); statusEl.innerHTML = ""; inputEl.setAttribute("aria-expanded", "false"); inputEl.setAttribute("aria-activedescendant", ""); + if (focusedOptionEl) { + focusedOptionEl.classList.remove(LIST_OPTION_FOCUSED_CLASS); + } + + listEl.scrollTop = 0; listEl.hidden = true; - listEl.innerHTML = ""; }; /** @@ -252,97 +399,104 @@ const hideList = el => { * @param {HTMLElement} listOptionEl The list option being selected */ const selectItem = listOptionEl => { - const { comboBoxEl, selectEl, inputEl } = getComboBoxElements(listOptionEl); + const { comboBoxEl, selectEl, inputEl } = getComboBoxContext(listOptionEl); - changeElementValue(selectEl, listOptionEl.dataset.optionValue); + changeElementValue(selectEl, listOptionEl.dataset.value); changeElementValue(inputEl, listOptionEl.textContent); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); hideList(comboBoxEl); inputEl.focus(); }; /** - * Select an option list of the combo box component based off of - * having a current focused list option or - * having test that completely matches a list option. - * Otherwise it clears the input and select. + * Clear the input of the combo box * - * @param {HTMLElement} el An element within the combo box component + * @param {HTMLButtonElement} clearButtonEl The clear input button */ -const completeSelection = el => { - const { selectEl, inputEl, statusEl, focusedOptionEl } = getComboBoxElements( - el +const clearInput = clearButtonEl => { + const { comboBoxEl, listEl, selectEl, inputEl } = getComboBoxContext( + clearButtonEl ); + const listShown = !listEl.hidden; - statusEl.textContent = ""; + if (selectEl.value) changeElementValue(selectEl); + if (inputEl.value) changeElementValue(inputEl); + comboBoxEl.classList.remove(COMBO_BOX_PRISTINE_CLASS); - if (focusedOptionEl) { - changeElementValue(selectEl, focusedOptionEl.dataset.optionValue); - changeElementValue(inputEl, focusedOptionEl.textContent); - return; - } + if (listShown) displayList(comboBoxEl); + inputEl.focus(); +}; + +/** + * Reset the select based off of currently set select value + * + * @param {HTMLElement} el An element within the combo box component + */ +const resetSelection = el => { + const { comboBoxEl, selectEl, inputEl } = getComboBoxContext(el); + const selectValue = selectEl.value; const inputValue = (inputEl.value || "").toLowerCase(); - if (inputValue) { + if (selectValue) { for (let i = 0, len = selectEl.options.length; i < len; i += 1) { const optionEl = selectEl.options[i]; - if (optionEl.text.toLowerCase() === inputValue) { - changeElementValue(selectEl, optionEl.value); - changeElementValue(inputEl, optionEl.text); + if (optionEl.value === selectValue) { + if (inputValue !== optionEl.text) { + changeElementValue(inputEl, optionEl.text); + } + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); return; } } } - if (selectEl.value) { - changeElementValue(selectEl); - } - - if (inputEl.value) { + if (inputValue) { changeElementValue(inputEl); } }; /** - * Manage the focused element within the list options when - * navigating via keyboard. + * Select an option list of the combo box component based off of + * having a current focused list option or + * having test that completely matches a list option. + * Otherwise it clears the input and select. * * @param {HTMLElement} el An element within the combo box component - * @param {HTMLElement} currentEl An element within the combo box component - * @param {HTMLElement} nextEl An element within the combo box component - * @param {boolean} preventScroll should skip procedure to scroll to element */ -const highlightOption = (el, currentEl, nextEl, preventScroll) => { - const { inputEl, listEl } = getComboBoxElements(el); - - if (currentEl) { - currentEl.classList.remove(LIST_OPTION_FOCUSED_CLASS); - currentEl.setAttribute("aria-selected", "false"); - } +const completeSelection = el => { + const { + comboBoxEl, + selectEl, + inputEl, + statusEl, + focusedOptionEl + } = getComboBoxContext(el); - if (nextEl) { - inputEl.setAttribute("aria-activedescendant", nextEl.id); - nextEl.setAttribute("aria-selected", "true"); - nextEl.classList.add(LIST_OPTION_FOCUSED_CLASS); + statusEl.textContent = ""; - if (!preventScroll) { - const optionBottom = nextEl.offsetTop + nextEl.offsetHeight; - const currentBottom = listEl.scrollTop + listEl.offsetHeight; + if (focusedOptionEl) { + changeElementValue(selectEl, focusedOptionEl.dataset.value); + changeElementValue(inputEl, focusedOptionEl.textContent); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + return; + } - if (optionBottom > currentBottom) { - listEl.scrollTop = optionBottom - listEl.offsetHeight; - } + const inputValue = (inputEl.value || "").toLowerCase(); - if (nextEl.offsetTop < listEl.scrollTop) { - listEl.scrollTop = nextEl.offsetTop; + if (inputValue) { + for (let i = 0, len = selectEl.options.length; i < len; i += 1) { + const optionEl = selectEl.options[i]; + if (optionEl.text.toLowerCase() === inputValue) { + changeElementValue(selectEl, optionEl.value); + changeElementValue(inputEl, optionEl.text); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + return; } } - - nextEl.focus({ preventScroll }); - } else { - inputEl.setAttribute("aria-activedescendant", ""); - inputEl.focus(); } + + resetSelection(comboBoxEl); }; /** @@ -351,9 +505,10 @@ const highlightOption = (el, currentEl, nextEl, preventScroll) => { * @param {KeyboardEvent} event An event within the combo box component */ const handleEscape = event => { - const { comboBoxEl, inputEl } = getComboBoxElements(event.target); + const { comboBoxEl, inputEl } = getComboBoxContext(event.target); hideList(comboBoxEl); + resetSelection(comboBoxEl); inputEl.focus(); }; @@ -363,7 +518,7 @@ const handleEscape = event => { * @param {KeyboardEvent} event An event within the combo box component */ const handleDown = event => { - const { comboBoxEl, listEl, focusedOptionEl } = getComboBoxElements( + const { comboBoxEl, listEl, focusedOptionEl } = getComboBoxContext( event.target ); @@ -371,9 +526,13 @@ const handleDown = event => { displayList(comboBoxEl); } - const nextOptionEl = focusedOptionEl - ? focusedOptionEl.nextSibling - : listEl.querySelector(LIST_OPTION); + let nextOptionEl = + listEl.querySelector(LIST_OPTION_SELECTED) || + listEl.querySelector(LIST_OPTION); + + if (focusedOptionEl) { + nextOptionEl = focusedOptionEl.nextSibling; + } if (nextOptionEl) { highlightOption(comboBoxEl, focusedOptionEl, nextOptionEl); @@ -388,24 +547,26 @@ const handleDown = event => { * @param {KeyboardEvent} event An event within the combo box component */ const handleEnterFromInput = event => { - const { comboBoxEl, listEl } = getComboBoxElements(event.target); + const { comboBoxEl, listEl } = getComboBoxContext(event.target); const listShown = !listEl.hidden; completeSelection(comboBoxEl); if (listShown) { hideList(comboBoxEl); - event.preventDefault(); } + + event.preventDefault(); }; /** - * Handle the enter event from an input element within the combo box component. + * Handle the tab event from an list option element within the combo box component. * * @param {KeyboardEvent} event An event within the combo box component */ -const handleTabFromInput = event => { - completeSelection(event.target); +const handleTabFromListOption = event => { + selectItem(event.target); + event.preventDefault(); }; /** @@ -424,7 +585,7 @@ const handleEnterFromListOption = event => { * @param {KeyboardEvent} event An event within the combo box component */ const handleUpFromListOption = event => { - const { comboBoxEl, listEl, focusedOptionEl } = getComboBoxElements( + const { comboBoxEl, listEl, focusedOptionEl } = getComboBoxContext( event.target ); const nextOptionEl = focusedOptionEl && focusedOptionEl.previousSibling; @@ -454,9 +615,41 @@ const handleMousemove = listOptionEl => { if (isCurrentlyFocused) return; - const { comboBoxEl, focusedOptionEl } = getComboBoxElements(listOptionEl); + const { comboBoxEl, focusedOptionEl } = getComboBoxContext(listOptionEl); + + highlightOption(comboBoxEl, focusedOptionEl, listOptionEl, { + preventScroll: true + }); +}; + +/** + * Toggle the list when the button is clicked + * + * @param {HTMLElement} el An element within the combo box component + */ +const toggleList = el => { + const { comboBoxEl, listEl, inputEl } = getComboBoxContext(el); + + if (listEl.hidden) { + displayList(comboBoxEl); + } else { + hideList(comboBoxEl); + } + + inputEl.focus(); +}; + +/** + * Handle click from input + * + * @param {HTMLInputElement} el An element within the combo box component + */ +const handleClickFromInput = el => { + const { comboBoxEl, listEl } = getComboBoxContext(el); - highlightOption(comboBoxEl, focusedOptionEl, listOptionEl, true); + if (listEl.hidden) { + displayList(comboBoxEl); + } }; const comboBox = behavior( @@ -464,42 +657,51 @@ const comboBox = behavior( [CLICK]: { [INPUT]() { if (this.disabled) return; - displayList(this); + handleClickFromInput(this); + }, + [TOGGLE_LIST_BUTTON]() { + if (this.disabled) return; + toggleList(this); }, [LIST_OPTION]() { + if (this.disabled) return; selectItem(this); + }, + [CLEAR_INPUT_BUTTON]() { + if (this.disabled) return; + clearInput(this); } }, focusout: { [COMBO_BOX](event) { - const { comboBoxEl } = getComboBoxElements(event.target); - if (!comboBoxEl.contains(event.relatedTarget)) { - hideList(comboBoxEl); + if (!this.contains(event.relatedTarget)) { + resetSelection(this); + hideList(this); } } }, keydown: { - [INPUT]: keymap({ + [COMBO_BOX]: keymap({ ArrowDown: handleDown, Down: handleDown, - Escape: handleEscape, - Enter: handleEnterFromInput, - Tab: handleTabFromInput + Escape: handleEscape + }), + [INPUT]: keymap({ + Enter: handleEnterFromInput }), [LIST_OPTION]: keymap({ ArrowUp: handleUpFromListOption, Up: handleUpFromListOption, - ArrowDown: handleDown, - Down: handleDown, - Escape: handleEscape, - Enter: handleEnterFromListOption + Enter: handleEnterFromListOption, + Tab: handleTabFromListOption, + "Shift+Tab": noop }) }, - keyup: { - [INPUT](event) { - if (isPrintableKeyCode(event.keyCode)) { - displayList(this); - } + input: { + [INPUT]() { + const comboBoxEl = this.closest(COMBO_BOX); + comboBoxEl.classList.remove(COMBO_BOX_PRISTINE_CLASS); + displayList(this); } }, mousemove: { @@ -513,7 +715,12 @@ const comboBox = behavior( select(COMBO_BOX, root).forEach(comboBoxEl => { enhanceComboBox(comboBoxEl); }); - } + }, + getComboBoxContext, + disable, + enable, + displayList, + hideList } ); diff --git a/src/stylesheets/elements/form-controls/_combo-box.scss b/src/stylesheets/elements/form-controls/_combo-box.scss index 5a11c90379..8fd7fa9296 100644 --- a/src/stylesheets/elements/form-controls/_combo-box.scss +++ b/src/stylesheets/elements/form-controls/_combo-box.scss @@ -2,16 +2,76 @@ position: relative; } +.usa-combo-box--pristine { + .usa-combo-box__input { + padding-right: calc(5em + 4px); + + &::-ms-clear { + display: none; + } + } + + .usa-combo-box__clear-input { + display: block; + } +} + .usa-combo-box__input { @extend %block-input-general; @extend %block-input-styles; - @include add-background-svg("arrow-down"); appearance: none; - background-color: color("white"); - background-position: right units(1.5) center; - background-size: units(2); + margin-bottom: 0; + padding-right: calc(2.5em + 3px); +} + +button.usa-combo-box__toggle-list, +button.usa-combo-box__clear-input { + &:focus { + outline-offset: -4px; + } +} + +.usa-combo-box__toggle-list__wrapper:focus, +.usa-combo-box__clear-input__wrapper:focus { + outline: 0; +} + +.usa-combo-box__toggle-list, +.usa-combo-box__clear-input { + background-color: transparent; + background-position: center; + background-size: auto units(1.5); + border: 0; + cursor: pointer; margin-bottom: 0; padding-right: units(4); + position: absolute; + top: 1px; + height: 2.25em; + z-index: z-index(100); +} +.usa-combo-box__clear-input { + @include add-background-svg("close-gray-60"); + + display: none; + right: calc(2.5em + 3px); +} + +.usa-combo-box__toggle-list { + @include add-background-svg("arrow-down-gray-60"); + right: 1px; +} + +.usa-combo-box__input-button-separator { + background-color: color("gray-cool-20"); + position: absolute; + top: 1px; + margin-bottom: 8px; + margin-top: 8px; + width: 1px; + right: calc(2.5em + 2px); + box-sizing: border-box; + z-index: z-index(200); } .usa-combo-box__list { @@ -37,14 +97,18 @@ padding: units(1); &--focused { - background-color: color("primary"); - border-color: color("primary"); - color: color("white"); + @include focus-outline($width: 2px, $offset: -2px, $color: "blue-warm-80v"); &:focus { outline-offset: -4px; } } + + &--selected { + background-color: color("primary"); + border-color: color("primary"); + color: color("white"); + } } .usa-combo-box__list-option--no-results {