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:
-
+
Select one...
ActionScript
AppleScript
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:
-
+
Select a language...
ActionScript
AppleScript
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 @@
\ No newline at end of file
+
diff --git a/spec/unit/combo-box/combo-box-placeholder.template.html b/spec/unit/combo-box/combo-box-placeholder.template.html
index a16a047bef..0e7514eea3 100755
--- a/spec/unit/combo-box/combo-box-placeholder.template.html
+++ b/spec/unit/combo-box/combo-box-placeholder.template.html
@@ -3,11 +3,7 @@
>Your preferred programming language:
-
+
Select a language...
ActionScript
AppleScript
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 {