diff --git a/cspell.json b/cspell.json
index 5404f893cf..9290c0804f 100644
--- a/cspell.json
+++ b/cspell.json
@@ -7,6 +7,7 @@
],
"words": [
"activedescendant",
+ "affordance",
"ahederson",
"alertdialog",
"amet",
@@ -79,6 +80,7 @@
"Garaventa",
"Geppy",
"gridcell",
+ "gridcells",
"GUIs",
"Gunderson",
"haspopup",
@@ -136,6 +138,7 @@
"Nemeth",
"Nihonium",
"nofollow",
+ "norotate",
"Nurthen",
"NVDA",
"Obel",
diff --git a/examples/combobox/combobox-datepicker.html b/examples/combobox/combobox-datepicker.html
new file mode 100644
index 0000000000..6656cc4458
--- /dev/null
+++ b/examples/combobox/combobox-datepicker.html
@@ -0,0 +1,845 @@
+
+
+
+
+Date Picker Combobox Example | WAI-ARIA Authoring Practices 1.2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Date Picker Combobox Example
+
+ NOTE: This example page is work in progress.
+ Please provide feedback in
+ pull request 1017.
+
+
+ The date picker is an example of the combobox
design pattern that opens a dialog box.
+ The date picker dialog box in this example is opened by moving keyboard focus to the text box and pressing either the enter key, down arrow key or alt + down arrow keys or by clicking on the choose date button.
+ The date picker dialog uses a grid
pattern to show and select a date using the cursor keys. Additional buttons in the dialog box can be used for changing the month and year shown in the grid.
+
+
+ Example
+
+
+
+
+
+
+
Date
+
+
+
+
+
+
+
+
+
+
+ Su
+ Mo
+ Tu
+ We
+ Th
+ Fr
+ Sa
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+
+
+ 9
+ 10
+ 11
+ 12
+ 13
+ 14
+ 15
+
+
+ 16
+ 17
+ 18
+ 19
+ 20
+ 21
+ 22
+
+
+ 23
+ 24
+ 25
+ 26
+ 27
+ 28
+ 29
+
+
+ 30
+ 31
+
+
+
+
+
+
+
+
+
+
+ Cancel
+ OK
+
+
+
+
+
+
+
+
+
+
+
+ Accessibility Features
+ There are three main features of the combobox date picker example:
+
+ Textbox: Holds the value of the selected date and can open the date picker dialog box.
+ Choose Date Button: Opens the datepicker dialog box.
+ Date Picker Dialog Box: Displays a grid of dates for the user to select from and provides additional buttons to change the month and year of dates shown in the grid.
+
+
+ The combobox
, textbox
and button
all have the same accessible name.
+
+
+ Date Textbox
+
+
+ Contains the date value.
+ Opens and closes the date picker dialog box through keyboard, click and touch events.
+ A live region provides information on how to open the date picker dialog when textbox receives focus (e.g. using down arrow key).
+ To support people who are sighted in understanding the down arrow key opens the date picker dialog box, a down arrow icon appears in the textbox
when the textbox
receives keyboard focus and the down arrow icon is removed when the textbox
does not have keyboard focus.
+
+
+ Choose Date Button
+
+
+ Opens and closes the date picker dialog box through click events.
+ Excluded from the tab sequence of the page (eg. tabindex=-1
), since the user can use the down arrow or enter key to open the date picker dialog box from the textbox
.
+
+
+ Date Picker Dialog
+
+
+ The date picker dialog is a modal dialog box for selecting a date from a grid of dates for a particular month and year.
+ Additional buttons and keyboard commands are used to change month and year.
+ A live region announces changes in the month and year.
+ A live region announces the use of cursor keys when a date in the grid of dates gets focus. The message is also visible at the bottom of the dialog box to provide the same information to keyboard users.
+ The dialog is opened through keyboard commands in the textbox or by clicking on the choose date button.
+ High contrast support for focus and hover styling of the controls in the dialog box use the CSS border property:
+
+ When a button or date receives focus a border is added.
+ When hovering over a button or date with a pointing device a border is added.
+ By default buttons and dates do not have a border, padding is used as a placeholder for the added border for focus and hover styling.
+
+
+
+ Mobile Support
+
+
+ One issue with mobile browsers is that the onscreen keyboard is visible when the textbox has focus.
+ Adding the touchstart
event allows the user to close the onscreen keyboard when the textbox has focus.
+
+
+
+
+
+
+ Keyboard Support
+
+
+ Textbox
+
+
+
+ Key
+ Function
+
+
+
+
+ Down Arrow , ALT + Down Arrow , Enter
+
+
+ Open the date picker dialog.
+
+ Move focus to selected date in the calendar grid, i.e., the date displayed in the date textbox.
+ If no date has been selected, places focus on the current date.
+
+
+
+
+
+
+
+
+
+ Choose Date Button
+
+
+
+ Key
+ Function
+
+
+
+
+ Space , Return
+
+
+ Open the date picker dialog.
+
+ Move focus to selected date in the calendar grid, i.e., the date displayed in the date textbox.
+ If no date has been selected, places focus on the current date.
+
+
+ Note:
+ The button has been excluded from Tab sequence of the page, since the dialog can be opened from the textbox.
+
+
+
+
+
+
+
+
+ Date Picker Dialog
+
+
+
+ Key
+ Function
+
+
+
+
+ ESC
+ Close the dialog and move focus back to the date textbox.
+
+
+ TAB
+
+
+ Moves focus to next element in the dialog Tab sequence.
+ Note that, as specified in the grid design pattern , only one button in the calendar grid is in the Tab sequence.
+ If focus is on the last button (i.e., OK ), moves focus to the first button (i.e. Previous Year ).
+
+
+
+
+ Shift + TAB
+
+
+ Moves focus to previous element in the dialog Tab sequence.
+ Note that, as specified in the grid design pattern , only one button in the calendar grid is in the Tab sequence.
+ If focus is on the first button (i.e., Previous Year ), moves focus to the last button (i.e. OK ).
+
+
+
+
+
+
+
+
+ Date Picker Dialog: Calendar Buttons
+
+
+
+ Key
+ Function
+
+
+
+
+ Space , Return
+
+ Change the month and/or year displayed in the calendar grid.
+
+
+
+
+
+
+
+ Date Picker Dialog: Date Grid
+
+
+
+ Key
+ Function
+
+
+
+
+ Space
+
+
+ Select the date.
+ Update the value of the Date input with the selected date.
+ Update the accessible name of the Choose Date button to include the selected date.
+
+
+
+ Enter
+
+
+ Select the date.
+ Update the value of the Date input with the selected date.
+ Update the accessible name of the Choose Date button to include the selected date.
+ Close the dialog, and move focus to the combobox.
+
+
+
+
+ Up Arrow
+ Move the focus to the same day of the previous week.
+
+
+ Down Arrow
+ Move the focus to the same day of the next week.
+
+
+ Right Arrow
+ Move the focus to the next day.
+
+
+ Left Arrow
+ Move the focus to the previous day.
+
+
+ Home
+ Move the focus to the first day (e.g Sunday) of the current week.
+
+
+ End
+ Move the focus to the last day (e.g. Saturday) of the current week.
+
+
+ PageUp
+
+
+ Changes the grid of dates to the previous month.
+ Sets focus on the same day of the same week. If that day does not exist, then moves focus to the same day of the previous or next week.
+
+
+
+
+ Shift + PageUp
+
+
+ Change the grid of dates to the previous year.
+ The focus will be on the same day of that week, if it does not exist, then it will move the focus to the same day of previous or next week
+
+
+
+
+ PageDown
+
+
+ Change the grid of dates to the next month.
+ The focus will be on the same day of that week, if it does not exist, then it will move the focus to the same day of previous or next week
+
+
+
+
+ Shift + PageDown
+
+
+ Change the grid of dates to the next year.
+ The focus will be on the same day of that week, if it does not exist, then it will move the focus to the same day of previous or next week
+
+
+
+
+
+
+
+
+ Date Picker Dialog: OK and Cancel Buttons
+
+
+
+ Key
+ Function
+
+
+
+
+ Space , Return
+
+ Activates the button:
+
+ Cancel : Closes the dialog, moves focus to combobox, does not update date in date textbox.
+ OK : Closes the dialog, moves focus to combobox, updates date in date textbox.
+
+
+
+
+
+
+
+
+
+ Role, Property, State, and Tabindex Attributes
+
+
+ Textbox
+
+
+
+ Role
+ Attribute
+ Element
+ Usage
+
+
+
+
+ role=combobox
+
+ input
+
+ Identifies the input
element as a combobox.
+
+
+
+
+ aria-haspopup=dialog
+ input
+
+ Identifies that the combobox opens a dialog box.
+
+
+
+
+ aria-expanded=false
+ input
+
+ aria-expanded=false
when the date picker dialog box is closed.
+
+
+
+
+ aria-expanded=true
+ input
+
+ aria-expanded=true
when the date picker dialog box is open.
+
+
+
+
+ aria-autocomplete=none
+ input
+
+ aria-autocomplete=none
the textbox does not support autocompletion when text is entered.
+
+
+
+
+ aria-controls=IDREF
+ input
+
+ Provides a reference to the screen reader for direct navigation to the dialog box.
+
+
+
+
+ aria-live=polite
+ div
+
+
+ Indicates the element that displays information about keyboard commands for opening the dialog box should be automatically announced by screen readers.
+ The script slightly delays display of the information so screen readers are more likely to read it after information related to change of focus.
+
+
+
+
+
+
+
+
+ Choose Date Button
+
+
+
+ Role
+ Attribute
+ Element
+ Usage
+
+
+
+
+
+ tabindex=-1
+ button
+
+
+ Exclude the button from tab sequence of the page.
+ The textbox supports opening the dialog through the keyboard.
+
+
+
+
+
+ aria-label="string"
+ button
+
+
+ The initial value of accessible name is Choose Date .
+ When users select a date, the accessible name is updated to also include the selected date.
+
+
+
+
+
+ aria-haspopup=dialog
+ button
+
+ Identifies the button opens a dialog box.
+
+
+
+
+ aria-expanded=false
+ button
+
+ aria-expanded=false
when the date picker dialog box is closed.
+
+
+
+
+ aria-expanded=true
+ button
+
+ aria-expanded=true
when the date picker dialog box is open.
+
+
+
+
+ aria-controls=IDREF
+ button
+
+ Provides a reference to the screen reader for direct navigation to the dialog box.
+
+
+
+
+
+
+
+ Date Picker Dialog
+
+
+
+ Role
+ Attribute
+ Element
+ Usage
+
+
+
+
+ dialog
+
+
+ div
+
+ Identifies the element as a dialog .
+
+
+
+ aria-modal=true
+ div
+ Indicates the dialog is modal.
+
+
+
+ aria-labelledby=IDREFS
+ div
+ Refers to the heading containing the currently displayed month and year, which defines the accessible name for the dialog.
+
+
+
+ aria-live=polite
+ div
+
+
+ Indicates the element that displays information about keyboard commands for navigating the grid should be automatically announced by screen readers.
+ The script slightly delays display of the information so screen readers are more likely to read it after information related to change of focus.
+
+
+
+
+
+
+
+
+ Date Picker Dialog: Calendar Navigation Buttons
+
+
+
+ Role
+ Attribute
+ Element
+ Usage
+
+
+
+
+
+ aria-label=String
+ button
+
+ Defines the accessible name of the button (e.g. "Next Year").
+
+
+
+
+ aria-live=polite
+
+ h2
+
+
+
+ When the month and/or year changes the content of the h2
element is updated.
+ The use of the polite
value means the screen reader will not interrupt other speech to announce the change in month and/or year.
+
+
+
+
+
+
+
+
+ Date Picker Dialog: Date Grid
+
+
+
+ Role
+ Attribute
+ Element
+ Usage
+
+
+
+
+ grid
+
+
+ table
+
+
+
+ Identifies the table
element as a grid
widget.
+ Since the grid
role is applied to a table
element, the row
, colheader
, and gridcell
roles do not need to be specified because they are implied by tr
, th
, and td
tags.
+
+
+
+
+
+ aria-labelledby=IDREF
+ table
+
+ Defines the accessible name for the grid
using the h2
that shows the month and year of the dates displayed in the grid.
+
+
+
+
+
+ tabindex="0"
+
+
+ td
+
+
+
+ Makes the gridcell focusable and includes it in the dialog Tab sequence.
+ Set dynamically by the JavaScript when the element is to be included in the dialog Tab sequence.
+ At any given time, only one gridcell within the grid is in the dialog Tab sequence.
+ This approach to managing focus is described in the section on roving tabindex .
+
+
+
+
+
+
+
+ tabindex="-1"
+
+
+ td
+
+
+
+ Makes the gridcell focusable and excludes it from the dialog Tab sequence.
+ Changed dynamically to 0
by the JavaScript when the gridcell is to be included in the dialog Tab sequence.
+ At any given time, only one gridcell within the grid is in the dialog Tab sequence.
+ This approach to managing focus is described in the section on roving tabindex .
+
+
+
+
+
+ aria-selected="true"
+ td
+
+
+ Identifies the gridcell for the currently selected date, i.e., the date value present in the date textbox.
+ Only set on the gridcell representing the currently selected date, no other gridcells have aria-selected
specified.
+
+
+
+
+
+
+ Note: Since the names of the days of the week in the column headers are abbreviated to two characters, they may be difficult to understand when announced by a screen reader.
+ An alternative column header name can be provided to screen readers by applying the abbr
attribute to the th
elements.
+ So, each th
element includes a abbr
attribute containing the full spelling of the name of the day for that column.
+
+
+
+
+
+ Javascript and CSS Source Code
+
+
+
+
+ HTML Source Code
+
+
+
+
+
+
+
+ combobox
Design Pattern in WAI-ARIA Authoring Practices 1.2
+
+
+
diff --git a/examples/combobox/css/combobox-datepicker.css b/examples/combobox/css/combobox-datepicker.css
new file mode 100644
index 0000000000..44c5ac0027
--- /dev/null
+++ b/examples/combobox/css/combobox-datepicker.css
@@ -0,0 +1,250 @@
+.combobox-datepicker {
+ margin-top: 1em;
+ position: relative;
+}
+
+.combobox-datepicker .group {
+ display: inline-flex;
+}
+
+.combobox-datepicker label {
+ display: block;
+}
+
+.combobox-datepicker input,
+.combobox-datepicker button {
+ background-color: white;
+ color: black;
+ box-sizing: border-box;
+ height: 1.75rem;
+ padding: 0;
+ margin: 0;
+ vertical-align: bottom;
+ border: 1px solid gray;
+ position: relative;
+}
+
+.combobox-datepicker input {
+ width: 10.75rem;
+ border-right: none;
+ outline: none;
+ font-size: 87.5%;
+ padding: 0.1em 0.3em;
+}
+
+.combobox-datepicker button {
+ width: 1.25rem;
+ border-left: none;
+ outline: none;
+}
+
+.combobox-datepicker .group.focus {
+ outline: 2px solid black;
+ outline-offset: 1px;
+}
+
+.combobox-datepicker .group.focus input,
+.combobox-datepicker .group.focus button {
+ background-color: #DEF;
+}
+
+.combobox-datepicker .group button[aria-expanded="true"] svg {
+ transform: rotate(180deg) translate(0, -1px);
+}
+
+.combobox-datepicker .group.focus {
+ outline: 2px solid black;
+ outline-offset: 1px;
+}
+
+.combobox-datepicker .group.focus input,
+.combobox-datepicker .group.focus button {
+ background-color: #DEF;
+}
+
+.combobox-datepicker .group polygon {
+ fill: gray;
+ stroke: transparent;
+}
+
+.combobox-datepicker .group button[aria-expanded="true"] polygon,
+.combobox-datepicker .group.focus polygon {
+ fill: black;
+ stroke: white;
+}
+
+.combobox-datepicker .dialog {
+ position: absolute;
+ width: 320px;
+ clear: both;
+ border: 3px solid hsl(216, 80%, 51%);
+ margin-top: 1em;
+ border-radius: 5px;
+ padding: 0;
+ background-color: #fff;
+}
+
+.combobox-datepicker .header {
+ cursor: default;
+ background-color: hsl(216, 80%, 51%);
+ padding: 7px;
+ font-weight: bold;
+ text-transform: uppercase;
+ color: white;
+ display: flex;
+ justify-content: space-around;
+}
+
+.combobox-datepicker .dialog h2 {
+ margin: 0;
+ padding: 0;
+ display: inline-block;
+ font-size: 1em;
+ color: white;
+ text-transform: none;
+ font-weight: bold;
+}
+
+.combobox-datepicker .dialog button {
+ border-style: none;
+ background: transparent;
+}
+
+.combobox-datepicker .dialog button::-moz-focus-inner {
+ border: 0;
+}
+
+.combobox-datepicker .dates {
+ width: 320px;
+}
+
+.combobox-datepicker .prev-year,
+.combobox-datepicker .prev-month,
+.combobox-datepicker .next-month,
+.combobox-datepicker .next-year {
+ padding: 4px;
+ width: 24px;
+ height: 24px;
+ color: white;
+}
+
+.combobox-datepicker .prev-year:focus,
+.combobox-datepicker .prev-month:focus,
+.combobox-datepicker .next-month:focus,
+.combobox-datepicker .next-year:focus {
+ padding: 2px;
+ border: 2px solid white;
+ border-radius: 4px;
+ outline: 0;
+}
+
+.combobox-datepicker .prev-year:hover,
+.combobox-datepicker .prev-month:hover,
+.combobox-datepicker .next-month:hover,
+.combobox-datepicker .next-year:hover {
+ padding: 3px;
+ border: 1px solid white;
+ border-radius: 4px;
+ outline: 0;
+}
+
+.combobox-datepicker .dialog-ok-cancel-group {
+ text-align: right;
+ margin-top: 1em;
+ margin-bottom: 1em;
+ margin-right: 1em;
+}
+
+.combobox-datepicker .dialog-ok-cancel-group button {
+ padding: 6px;
+ margin-left: 1em;
+ width: 5em;
+ background-color: hsl(216, 80%, 92%);
+ font-size: 0.85em;
+ color: black;
+ outline: none;
+ border-radius: 5px;
+}
+
+.combobox-datepicker .dialog-button:focus {
+ padding: 4px;
+ border: 2px solid black;
+}
+
+.combobox-datepicker .dialog-button:hover {
+ padding: 5px;
+ border: 1px solid black;
+}
+
+
+.combobox-datepicker .fa-calendar-alt {
+ color: hsl(216, 89%, 51%);
+}
+
+.combobox-datepicker .month-year {
+ display: inline-block;
+ width: 12em;
+ text-align: center;
+}
+
+.combobox-datepicker .dates {
+ padding-left: 1em;
+ padding-right: 1em;
+ padding-top: 1em;
+}
+
+.combobox-datepicker .dates th,
+.combobox-datepicker .dates td {
+ text-align: center;
+}
+
+.combobox-datepicker .dates tr {
+ border: 1px solid black;
+}
+
+.combobox-datepicker .dates td {
+ padding: 3px;
+ margin: 0;
+ line-height: inherit;
+ height: 40px;
+ width: 40px;
+ border-radius: 5px;
+ font-size: 15px;
+ background: #eee;
+}
+
+
+.combobox-datepicker .dates td[aria-selected] {
+ padding: 1px;
+ border: 2px dotted black;
+ background-color: hsl(216, 80%, 96%);
+}
+
+.combobox-datepicker .dates td[tabindex="0"] {
+ background-color: hsl(216, 80%, 92%);
+}
+
+.combobox-datepicker .dates td:focus,
+.combobox-datepicker .dates td:hover {
+ padding: 0;
+ background-color: hsl(216, 80%, 92%);
+}
+
+.combobox-datepicker .dates td:not(.disabled):hover {
+ padding: 2px;
+ border: 1px solid rgb(100, 100, 100);
+}
+
+.combobox-datepicker .dates td:focus {
+ padding: 1px;
+ border: 2px solid rgb(100, 100, 100);
+ outline: 0;
+}
+
+.combobox-datepicker .dialog-message {
+ padding-top: 0.25em;
+ padding-left: 1em;
+ height: 1.75em;
+ background: hsl(216, 80%, 51%);
+ color: white;
+}
diff --git a/examples/combobox/css/datepicker-combobox.css b/examples/combobox/css/datepicker-combobox.css
new file mode 100644
index 0000000000..800cddaa3d
--- /dev/null
+++ b/examples/combobox/css/datepicker-combobox.css
@@ -0,0 +1,203 @@
+.datepickerCombobox {
+ margin-top: 1em;
+}
+
+.datepickerCombobox button.icon {
+ border-style: none;
+ text-align: left;
+ background-color: white;
+ position: relative;
+ left: -25px;
+ top: 3px;
+}
+
+.datepickerCombobox span.arrow {
+ margin: 0;
+ padding: 0;
+ display: none;
+ background: transparent;
+}
+
+.datepickerCombobox span.arrow svg polygon {
+ stroke: gray;
+ stroke-width: 1px;
+ fill: gray;
+}
+
+.datepickerCombobox span.arrow.up.show {
+ display: inline-block;
+ position: relative;
+ left: -23px;
+}
+
+.datepickerCombobox span.arrow.down.show {
+ display: inline-block;
+ position: relative;
+ left: -23px;
+}
+
+.datepickerCombobox .datepickerDialog {
+ width: auto;
+ position: absolute;
+ clear: both;
+ display: none;
+ border: 3px solid hsl(216, 80%, 55%);
+ margin-top: 1em;
+ border-radius: 5px;
+ padding: 0;
+ background-color: #fff;
+}
+
+.datepickerCombobox .header {
+ cursor: default;
+ background-color: hsl(216, 80%, 55%);
+ padding: 7px;
+ font-weight: bold;
+ text-transform: uppercase;
+ color: white;
+ display: flex;
+ justify-content: space-around;
+}
+
+.datepickerCombobox .header h2 {
+ margin: 0;
+ padding: 0;
+ display: inline-block;
+ font-size: 1em;
+ color: white;
+ text-transform: none;
+ font-weight: bold;
+}
+
+.datepickerCombobox .header button {
+ border-style: none;
+ background: transparent;
+}
+
+.datepickerCombobox .datepickerDialog button::-moz-focus-inner {
+ border: 0;
+}
+
+.datepickerCombobox .prevYear,
+.datepickerCombobox .prevMonth,
+.datepickerCombobox .nextMonth,
+.datepickerCombobox .nextYear {
+ padding: 4px;
+ width: 24px;
+ height: 24px;
+ color: white;
+}
+
+.datepickerCombobox .prevYear:focus,
+.datepickerCombobox .prevMonth:focus,
+.datepickerCombobox .nextMonth:focus,
+.datepickerCombobox .nextYear:focus {
+ padding: 2px;
+ border: 2px solid white;
+ border-radius: 4px;
+ outline: 0;
+}
+
+.datepickerCombobox .dialogButtonGroup {
+ text-align: right;
+ margin-top: 1em;
+ margin-bottom: 1em;
+ margin-right: 1em;
+}
+
+.datepickerCombobox .dialogButton {
+ padding: 5px;
+ margin-left: 1em;
+ width: 5em;
+ background-color: hsl(216, 80%, 92%);
+ font-size: 0.85em;
+ color: black;
+ outline: none;
+ border: 1px solid hsl(216, 80%, 92%);
+ border-radius: 5px;
+}
+
+.datepickerCombobox .dialogButton:focus {
+ padding: 4px;
+ border: 2px solid black;
+}
+
+.datepickerCombobox .fa-calendar-alt {
+ color: hsl(216, 89%, 72%);
+}
+
+.datepickerCombobox .monthYear {
+ display: inline-block;
+ width: 12em;
+ text-align: center;
+}
+
+.datepickerCombobox table.dates {
+ width: 100%;
+ padding-left: 1em;
+ padding-right: 1em;
+ padding-top: 1em;
+}
+
+.datepickerCombobox table.dates th,
+.datepickerCombobox table.dates td {
+ text-align: center;
+}
+
+.datepickerCombobox .dateRow {
+ border: 1px solid black;
+}
+
+.datepickerCombobox .dateCell {
+ height: 40px;
+ width: 40px;
+}
+
+.datepickerCombobox .dateButton {
+ padding: 0;
+ margin: 0;
+ line-height: inherit;
+ height: 100%;
+ width: 100%;
+ border: 1px solid #eee;
+ border-radius: 5px;
+ outline: none;
+ font-size: 15px;
+ background: #eee;
+}
+
+.datepickerCombobox .dateButton:focus,
+.datepickerCombobox .dateButton:hover {
+ padding: 0;
+ background-color: hsl(216, 80%, 92%);
+}
+
+.datepickerCombobox .dateButton:focus {
+ border-width: 2px;
+ border-color: rgb(100, 100, 100);
+ outline: 0;
+}
+
+.datepickerCombobox .dateButton[aria-selected] {
+ border-color: rgb(100, 100, 100);
+}
+
+.datepickerCombobox .dateButton[tabindex="0"] {
+ background-color: hsl(216, 80%, 92%);
+}
+
+.datepickerCombobox .dateButton.disabled {
+ visibility: hidden;
+}
+
+.datepickerCombobox .comboboxMessage {
+ display: none;
+}
+
+.datepickerCombobox .dialogMessage {
+ padding-top: 0.25em;
+ padding-left: 1em;
+ height: 1.75em;
+ background: hsl(216, 80%, 55%);
+ color: white;
+}
diff --git a/examples/combobox/js/combobox-datepicker.js b/examples/combobox/js/combobox-datepicker.js
new file mode 100644
index 0000000000..347e77aebf
--- /dev/null
+++ b/examples/combobox/js/combobox-datepicker.js
@@ -0,0 +1,843 @@
+/*
+* This content is licensed according to the W3C Software License at
+* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
+*
+* File: ComboboxDatePicker.js
+*/
+
+var ComboboxDatePicker = function (cdp) {
+ this.buttonLabel = 'Date';
+ this.dayLabels = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+ this.monthLabels = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+
+ this.messageCursorKeys = 'Cursor keys can navigate dates';
+ this.lastMessage = '';
+
+ this.comboboxNode = cdp.querySelector('input[type="text"');
+ this.buttonNode = cdp.querySelector('.group button');
+ this.dialogNode = cdp.querySelector('[role="dialog"]');
+ this.messageNode = this.dialogNode.querySelector('.dialog-message');
+
+ this.monthYearNode = this.dialogNode.querySelector('.month-year');
+
+ this.prevYearNode = this.dialogNode.querySelector('.prev-year');
+ this.prevMonthNode = this.dialogNode.querySelector('.prev-month');
+ this.nextMonthNode = this.dialogNode.querySelector('.next-month');
+ this.nextYearNode = this.dialogNode.querySelector('.next-year');
+
+ this.okButtonNode = this.dialogNode.querySelector('button[value="ok"]');
+ this.cancelButtonNode = this.dialogNode.querySelector('button[value="cancel"]');
+
+ this.tbodyNode = this.dialogNode.querySelector('table.dates tbody');
+
+ this.lastRowNode = null;
+
+ this.days = [];
+
+ this.focusDay = new Date();
+ this.selectedDay = new Date(0,0,1);
+
+ this.isMouseDownOnBackground = false;
+
+};
+
+ComboboxDatePicker.prototype.init = function () {
+
+ this.comboboxNode.addEventListener('keydown', this.handleComboboxKeyDown.bind(this));
+ this.comboboxNode.addEventListener('mouseup', this.handleComboboxMouseUp.bind(this));
+ this.comboboxNode.addEventListener('focus', this.handleComboboxFocus.bind(this));
+ this.comboboxNode.addEventListener('blur', this.handleComboboxBlur.bind(this));
+
+ this.buttonNode.addEventListener('keydown', this.handleButtonKeyDown.bind(this));
+ this.buttonNode.addEventListener('mouseup', this.handleButtonMouseUp.bind(this));
+
+ this.okButtonNode.addEventListener('click', this.handleOkButton.bind(this));
+ this.okButtonNode.addEventListener('keydown', this.handleOkButton.bind(this));
+
+ this.cancelButtonNode.addEventListener('click', this.handleCancelButton.bind(this));
+ this.cancelButtonNode.addEventListener('keydown', this.handleCancelButton.bind(this));
+
+ this.prevMonthNode.addEventListener('click', this.handlePreviousMonthButton.bind(this));
+ this.nextMonthNode.addEventListener('click', this.handleNextMonthButton.bind(this));
+ this.prevYearNode.addEventListener('click', this.handlePreviousYearButton.bind(this));
+ this.nextYearNode.addEventListener('click', this.handleNextYearButton.bind(this));
+
+ this.prevMonthNode.addEventListener('keydown', this.handlePreviousMonthButton.bind(this));
+ this.nextMonthNode.addEventListener('keydown', this.handleNextMonthButton.bind(this));
+ this.prevYearNode.addEventListener('keydown', this.handlePreviousYearButton.bind(this));
+ this.nextYearNode.addEventListener('keydown', this.handleNextYearButton.bind(this));
+
+ document.body.addEventListener('mouseup', this.handleBackgroundMouseUp.bind(this), true);
+
+ // Create Grid of Dates
+
+ this.tbodyNode.innerHTML = '';
+ for (var i = 0; i < 6; i++) {
+ var row = this.tbodyNode.insertRow(i);
+ this.lastRowNode = row;
+ for (var j = 0; j < 7; j++) {
+ var cell = document.createElement('td');
+
+ cell.setAttribute('tabindex', '-1');
+ cell.addEventListener('click', this.handleDayClick.bind(this));
+ cell.addEventListener('keydown', this.handleDayKeyDown.bind(this));
+ cell.addEventListener('focus', this.handleDayFocus.bind(this));
+
+ cell.innerHTML = '-1';
+
+ row.appendChild(cell);
+ this.days.push(cell);
+ }
+ }
+
+ this.updateGrid();
+ this.close(false);
+};
+
+ComboboxDatePicker.prototype.isSameDay = function (day1, day2) {
+ return (day1.getFullYear() == day2.getFullYear()) &&
+ (day1.getMonth() == day2.getMonth()) &&
+ (day1.getDate() == day2.getDate());
+};
+
+ComboboxDatePicker.prototype.isNotSameMonth = function (day1, day2) {
+ return (day1.getFullYear() != day2.getFullYear()) ||
+ (day1.getMonth() != day2.getMonth());
+};
+
+ComboboxDatePicker.prototype.updateGrid = function () {
+
+ var i, flag;
+ var fd = this.focusDay;
+
+ this.monthYearNode.innerHTML = this.monthLabels[fd.getMonth()] + ' ' + fd.getFullYear();
+
+ var firstDayOfMonth = new Date(fd.getFullYear(), fd.getMonth(), 1);
+ var dayOfWeek = firstDayOfMonth.getDay();
+
+ firstDayOfMonth.setDate(firstDayOfMonth.getDate() - dayOfWeek);
+
+ var d = new Date(firstDayOfMonth);
+
+ for (i = 0; i < this.days.length; i++) {
+ flag = d.getMonth() != fd.getMonth();
+ this.updateDate(this.days[i], flag, d, this.isSameDay(d, this.selectedDay));
+ d.setDate(d.getDate() + 1);
+
+ // Hide last row if all disabled dates
+ if (i === 35) {
+ if (flag) {
+ this.lastRowNode.style.visibility = 'hidden';
+ }
+ else {
+ this.lastRowNode.style.visibility = 'visible';
+ }
+ }
+ }
+};
+
+ComboboxDatePicker.prototype.setFocusDay = function (flag) {
+
+ if (typeof flag !== 'boolean') {
+ flag = true;
+ }
+
+ var fd = this.focusDay;
+ var getDayFromDataDateAttribute = this.getDayFromDataDateAttribute;
+
+ function checkDay (domNode) {
+
+ var d = getDayFromDataDateAttribute(domNode);
+
+ domNode.setAttribute('tabindex', '-1');
+ if (this.isSameDay(d, fd)) {
+ domNode.setAttribute('tabindex', '0');
+ if (flag) {
+ domNode.focus();
+ }
+ }
+ }
+
+
+ this.days.forEach(checkDay.bind(this));
+
+};
+
+ComboboxDatePicker.prototype.updateDay = function (day) {
+ var d = this.focusDay;
+ this.focusDay = day;
+ if (this.isNotSameMonth(d, day)) {
+ this.updateGrid();
+ this.setFocusDay();
+ }
+};
+
+ComboboxDatePicker.prototype.open = function () {
+ this.dialogNode.style.display = 'block';
+ this.dialogNode.style.zIndex = 2;
+
+ this.comboboxNode.setAttribute('aria-expanded', 'true')
+ this.buttonNode.setAttribute('aria-expanded', 'true')
+ this.getDateFromCombobox();
+ this.updateGrid();
+};
+
+ComboboxDatePicker.prototype.isOpen = function () {
+ return window.getComputedStyle(this.dialogNode).display !== 'none';
+};
+
+ComboboxDatePicker.prototype.close = function (flag) {
+ if (typeof flag !== 'boolean') {
+ // Default is to move focus to combobox
+ flag = true;
+ }
+
+ this.setMessage('');
+ this.dialogNode.style.display = 'none';
+ this.comboboxNode.setAttribute('aria-expanded', 'false')
+ this.buttonNode.setAttribute('aria-expanded', 'false')
+
+ if (flag) {
+ this.comboboxNode.focus();
+ }
+};
+
+ComboboxDatePicker.prototype.handleOkButton = function (event) {
+ var flag = false;
+
+ switch (event.type) {
+ case 'keydown':
+
+ switch (event.key) {
+ case "Tab":
+ if (!event.shiftKey) {
+ this.prevYearNode.focus();
+ flag = true;
+ }
+ break;
+
+ case "Esc":
+ case "Escape":
+ this.close();
+ flag = true;
+ break;
+
+ default:
+ break;
+
+ }
+ break;
+
+ case 'click':
+ this.setComboboxDate();
+ this.close();
+ flag = true;
+ break;
+
+ default:
+ break;
+ }
+
+ if (flag) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+};
+
+ComboboxDatePicker.prototype.handleCancelButton = function (event) {
+ var flag = false;
+
+ switch (event.type) {
+ case 'keydown':
+
+ switch (event.key) {
+
+ case "Esc":
+ case "Escape":
+ this.close();
+ flag = true;
+ break;
+
+ default:
+ break;
+
+ }
+ break;
+
+ case 'click':
+ this.close();
+ flag = true;
+ break;
+
+ default:
+ break;
+ }
+
+ if (flag) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+};
+
+ComboboxDatePicker.prototype.handleNextYearButton = function (event) {
+ var flag = false;
+
+ switch (event.type) {
+
+ case 'keydown':
+
+ switch (event.key) {
+ case "Esc":
+ case "Escape":
+ this.close();
+ flag = true;
+ break;
+
+ case "Enter":
+ this.moveToNextYear();
+ this.setFocusDay(false);
+ flag = true;
+ break;
+ }
+
+ break;
+
+ case 'click':
+ this.moveToNextYear();
+ this.setFocusDay(false);
+ break;
+
+ default:
+ break;
+ }
+
+ if (flag) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+};
+
+ComboboxDatePicker.prototype.handlePreviousYearButton = function (event) {
+ var flag = false;
+
+ switch (event.type) {
+
+ case 'keydown':
+
+ switch (event.key) {
+
+ case "Enter":
+ this.moveToPreviousYear();
+ this.setFocusDay(false);
+ flag = true;
+ break;
+
+ case "Tab":
+ if (event.shiftKey) {
+ this.okButtonNode.focus();
+ flag = true;
+ }
+ break;
+
+ case "Esc":
+ case "Escape":
+ this.close();
+ flag = true;
+ break;
+
+ default:
+ break;
+ }
+
+ break;
+
+ case 'click':
+ this.moveToPreviousYear();
+ this.setFocusDay(false);
+ break;
+
+ default:
+ break;
+ }
+
+ if (flag) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+};
+
+ComboboxDatePicker.prototype.handleNextMonthButton = function (event) {
+ var flag = false;
+
+ switch (event.type) {
+
+ case 'keydown':
+
+ switch (event.key) {
+ case "Esc":
+ case "Escape":
+ this.close();
+ flag = true;
+ break;
+
+ case "Enter":
+ this.moveToNextMonth();
+ this.setFocusDay(false);
+ flag = true;
+ break;
+ }
+
+ break;
+
+ case 'click':
+ this.moveToNextMonth();
+ this.setFocusDay(false);
+ break;
+
+ default:
+ break;
+ }
+
+ if (flag) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+};
+
+ComboboxDatePicker.prototype.handlePreviousMonthButton = function (event) {
+ var flag = false;
+
+ switch (event.type) {
+
+ case 'keydown':
+
+ switch (event.key) {
+ case "Esc":
+ case "Escape":
+ this.close();
+ flag = true;
+ break;
+
+ case "Enter":
+ this.moveToPreviousMonth();
+ this.setFocusDay(false);
+ flag = true;
+ break;
+ }
+
+ break;
+
+ case 'click':
+ this.moveToPreviousMonth();
+ this.setFocusDay(false);
+ flag = true;
+ break;
+
+ default:
+ break;
+ }
+
+ if (flag) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+};
+
+ComboboxDatePicker.prototype.moveFocusToDay = function (day) {
+ var d = this.focusDay;
+
+ this.focusDay = day;
+
+ if ((d.getMonth() != this.focusDay.getMonth()) ||
+ (d.getYear() != this.focusDay.getYear())) {
+ this.updateGrid();
+ }
+ this.setFocusDay();
+};
+
+
+ComboboxDatePicker.prototype.moveToNextYear = function () {
+ this.focusDay.setFullYear(this.focusDay.getFullYear() + 1);
+ this.updateGrid();
+};
+
+ComboboxDatePicker.prototype.moveToPreviousYear = function () {
+ this.focusDay.setFullYear(this.focusDay.getFullYear() - 1);
+ this.updateGrid();
+};
+
+ComboboxDatePicker.prototype.moveToNextMonth = function () {
+ this.focusDay.setMonth(this.focusDay.getMonth() + 1);
+ this.updateGrid();
+};
+
+ComboboxDatePicker.prototype.moveToPreviousMonth = function () {
+ this.focusDay.setMonth(this.focusDay.getMonth() - 1);
+ this.updateGrid();
+};
+
+ComboboxDatePicker.prototype.moveFocusToNextDay = function () {
+ var d = new Date(this.focusDay);
+ d.setDate(d.getDate() + 1);
+ this.moveFocusToDay(d);
+};
+
+ComboboxDatePicker.prototype.moveFocusToNextWeek = function () {
+ var d = new Date(this.focusDay);
+ d.setDate(d.getDate() + 7);
+ this.moveFocusToDay(d);
+};
+
+ComboboxDatePicker.prototype.moveFocusToPreviousDay = function () {
+ var d = new Date(this.focusDay);
+ d.setDate(d.getDate() - 1);
+ this.moveFocusToDay(d);
+};
+
+ComboboxDatePicker.prototype.moveFocusToPreviousWeek = function () {
+ var d = new Date(this.focusDay);
+ d.setDate(d.getDate() - 7);
+ this.moveFocusToDay(d);
+};
+
+ComboboxDatePicker.prototype.moveFocusToFirstDayOfWeek = function () {
+ var d = new Date(this.focusDay);
+ d.setDate(d.getDate() - d.getDay());
+ this.moveFocusToDay(d);
+};
+
+ComboboxDatePicker.prototype.moveFocusToLastDayOfWeek = function () {
+ var d = new Date(this.focusDay);
+ d.setDate(d.getDate() + (6 - d.getDay()));
+ this.moveFocusToDay(d);
+};
+
+// Day methods
+
+ComboboxDatePicker.prototype.isDayDisabled = function (domNode) {
+ return domNode.classList.contains('disabled');
+};
+
+ComboboxDatePicker.prototype.getDayFromDataDateAttribute = function (domNode) {
+ var parts = domNode.getAttribute('data-date').split('-');
+ return new Date(parts[0], parseInt(parts[1])-1, parts[2]);
+};
+
+ComboboxDatePicker.prototype.updateDate = function (domNode, disable, day, selected) {
+
+ var d = day.getDate().toString();
+ if (day.getDate() <= 9) {
+ d = '0' + d;
+ }
+
+ var m = day.getMonth() + 1;
+ if (day.getMonth() < 9) {
+ m = '0' + m;
+ }
+
+ domNode.setAttribute('tabindex', '-1');
+ domNode.removeAttribute('aria-selected');
+ domNode.setAttribute('data-date', day.getFullYear() + '-' + m + '-' + d);
+
+ if (disable) {
+ domNode.classList.add('disabled');
+ domNode.innerHTML = '';
+ }
+ else {
+ domNode.classList.remove('disabled');
+ domNode.innerHTML = day.getDate();
+ if (selected) {
+ domNode.setAttribute('aria-selected', 'true');
+ domNode.setAttribute('tabindex', '0');
+ }
+ }
+
+};
+
+ComboboxDatePicker.prototype.updateSelected = function (domNode) {
+ for (i = 0; i < this.days.length; i++) {
+ var day = this.days[i];
+ if (day === domNode) {
+ day.setAttribute('aria-selected', 'true');
+ }
+ else {
+ day.removeAttribute('aria-selected');
+ }
+ }
+};
+
+
+ComboboxDatePicker.prototype.handleDayKeyDown = function (event) {
+ var flag = false;
+
+ switch (event.key) {
+
+ case "Esc":
+ case "Escape":
+ this.close();
+ break;
+
+ case " ":
+ this.updateSelected(event.currentTarget);
+ this.setComboboxDate(event.currentTarget);
+ flag = true;
+ break;
+
+ case "Enter":
+ this.setComboboxDate(event.currentTarget);
+ this.close();
+ break;
+
+ case "Tab":
+ this.cancelButtonNode.focus();
+ if (event.shiftKey) {
+ this.nextYearNode.focus();
+ }
+ this.setMessage('');
+ flag = true;
+ break;
+
+ case "Right":
+ case "ArrowRight":
+ this.moveFocusToNextDay();
+ flag = true;
+ break;
+
+ case "Left":
+ case "ArrowLeft":
+ this.moveFocusToPreviousDay();
+ flag = true;
+ break;
+
+ case "Down":
+ case "ArrowDown":
+ this.moveFocusToNextWeek();
+ flag = true;
+ break;
+
+ case "Up":
+ case "ArrowUp":
+ this.moveFocusToPreviousWeek();
+ flag = true;
+ break;
+
+ case "PageUp":
+ if (event.shiftKey) {
+ this.moveToPreviousYear();
+ }
+ else {
+ this.moveToPreviousMonth();
+ }
+ this.setFocusDay();
+ flag = true;
+ break;
+
+ case "PageDown":
+ if (event.shiftKey) {
+ this.moveToNextYear();
+ }
+ else {
+ this.moveToNextMonth();
+ }
+ this.setFocusDay();
+ flag = true;
+ break;
+
+ case "Home":
+ this.moveFocusToFirstDayOfWeek();
+ flag = true;
+ break;
+
+ case "End":
+ this.moveFocusToLastDayOfWeek();
+ flag = true;
+ break;
+ }
+
+ if (flag) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+};
+
+ComboboxDatePicker.prototype.handleDayClick = function (event) {
+
+ if (!this.isDayDisabled(event.currentTarget)) {
+ this.setComboboxDate(event.currentTarget);
+ this.close();
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+};
+
+ComboboxDatePicker.prototype.handleDayFocus = function () {
+ this.setMessage(this.messageCursorKeys);
+};
+
+// Combobox methods
+
+ComboboxDatePicker.prototype.setComboboxDate = function (domNode) {
+
+ var d = this.focusDay;
+
+ if (domNode) {
+ d = this.getDayFromDataDateAttribute(domNode);
+ }
+
+ this.comboboxNode.value = (d.getMonth() + 1) + '/' + d.getDate() + '/' + d.getFullYear();
+
+};
+
+ComboboxDatePicker.prototype.getDateFromCombobox = function () {
+
+ var parts = this.comboboxNode.value.split('/');
+
+ if ((parts.length === 3) &&
+ Number.isInteger(parseInt(parts[0])) &&
+ Number.isInteger(parseInt(parts[1])) &&
+ Number.isInteger(parseInt(parts[2]))) {
+ this.focusDay = new Date(parseInt(parts[2]), parseInt(parts[0]) - 1, parseInt(parts[1]));
+ this.selectedDay = new Date(this.focusDay);
+ }
+ else {
+ // If not a valid date (MM/DD/YY) initialize with todays date
+ this.focusDay = new Date();
+ this.selectedDay = new Date(0,0,1);
+ }
+
+};
+
+ComboboxDatePicker.prototype.setMessage = function (str) {
+
+ function setMessageDelayed () {
+ this.messageNode.textContent = str;
+ }
+
+ if (str !== this.lastMessage) {
+ setTimeout(setMessageDelayed.bind(this), 200);
+ this.lastMessage = str;
+ }
+};
+
+ComboboxDatePicker.prototype.setFocusCombobox = function () {
+ this.comboboxNode.focus();
+};
+
+ComboboxDatePicker.prototype.handleComboboxKeyDown = function (event) {
+ var flag = false,
+ char = event.key,
+ altKey = event.altKey;
+
+ if (event.ctrlKey || event.shiftKey) {
+ return;
+ }
+
+ switch (event.key) {
+
+ case "Enter":
+ case "Down":
+ case "ArrowDown":
+ this.open();
+ this.setFocusDay();
+ flag = true;
+ break;
+
+ case "Esc":
+ case "Escape":
+ if (this.isOpen()) {
+ this.close(false);
+ }
+ else {
+ this.comboboxNode.value = '';
+ }
+ this.option = null;
+ flag = true;
+ break;
+
+ case "Tab":
+ this.close(false);
+ break;
+
+
+ default:
+ break;
+ }
+
+ if (flag) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+
+};
+
+ComboboxDatePicker.prototype.handleComboboxMouseUp = function (event) {
+ if (this.isOpen()) {
+ this.close(false);
+ }
+ else {
+ this.open();
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+};
+
+ComboboxDatePicker.prototype.handleComboboxFocus = function (event) {
+ event.currentTarget.parentNode.classList.add('focus');
+};
+
+ComboboxDatePicker.prototype.handleComboboxBlur = function (event) {
+ event.currentTarget.parentNode.classList.remove('focus');
+};
+
+ComboboxDatePicker.prototype.handleButtonKeyDown = function (event) {
+
+ if (event.key === "Enter" || event.key === " ") {
+ this.open();
+ this.setFocusDay();
+
+ event.stopPropagation();
+ event.preventDefault();
+ }
+};
+
+ComboboxDatePicker.prototype.handleButtonMouseUp = function (event) {
+ if (this.isOpen()) {
+ this.close();
+ }
+ else {
+ this.open();
+ this.setFocusCombobox();
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+};
+
+ComboboxDatePicker.prototype.handleBackgroundMouseUp = function (event) {
+ if (!this.comboboxNode.contains(event.target) &&
+ !this.buttonNode.contains(event.target) &&
+ !this.dialogNode.contains(event.target)) {
+
+ if (this.isOpen()) {
+ this.close(false);
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ }
+};
+
+// Initialize menu button date picker
+
+window.addEventListener('load' , function () {
+
+ var comboboxDatePickers = document.querySelectorAll('.combobox-datepicker');
+
+ comboboxDatePickers.forEach(function (dp) {
+ var datePicker = new ComboboxDatePicker(dp);
+ datePicker.init();
+ });
+
+});
diff --git a/examples/dialog-modal/datepicker-dialog.html b/examples/dialog-modal/datepicker-dialog.html
index 631e07373c..5f14b3bc80 100644
--- a/examples/dialog-modal/datepicker-dialog.html
+++ b/examples/dialog-modal/datepicker-dialog.html
@@ -119,6 +119,7 @@ Example
+
25
26
27
@@ -127,15 +128,6 @@ Example
30
1
-
- 2
- 3
- 4
- 5
- 6
- 7
- 8
-
9
10
@@ -145,15 +137,6 @@ Example
14
15
-
- 16
- 17
- 18
- 19
- 20
- 21
- 22
-
23
24
diff --git a/test/tests/combobox_datepicker.js b/test/tests/combobox_datepicker.js
new file mode 100644
index 0000000000..b9f04e7090
--- /dev/null
+++ b/test/tests/combobox_datepicker.js
@@ -0,0 +1,483 @@
+'use strict';
+
+const { ariaTest } = require('..');
+const { By, Key } = require('selenium-webdriver');
+const assertAttributeValues = require('../util/assertAttributeValues');
+const assertAttributeDNE = require('../util/assertAttributeDNE');
+const assertAriaControls = require('../util/assertAriaControls');
+const assertAriaLabelledby = require('../util/assertAriaLabelledby');
+const assertAriaDescribedby = require('../util/assertAriaDescribedby');
+const assertAriaLabelExists = require('../util/assertAriaLabelExists');
+const assertAriaRoles = require('../util/assertAriaRoles');
+const assertRovingTabindex = require('../util/assertRovingTabindex');
+const assertTabOrder = require('../util/assertTabOrder');
+
+const exampleFile = 'combobox/combobox-datepicker.html';
+
+let today = new Date();
+let todayDataDate = today.toISOString().split('T')[0];
+
+const ex = {
+ comboboxSelector: '#ex1 .group input',
+ buttonSelector: '#ex1 .group button',
+ dialogSelector: '#ex1 [role="dialog"]',
+ cancelSelector: '#ex1 [role="dialog"] button[value="cancel"]',
+ dialogMessageSelector: '#ex1 .dialog-message',
+ gridSelector: '#ex1 [role="grid"]',
+ controlButtons: '#ex1 [role="dialog"] .header button',
+ currentMonthDateButtons: '#ex1 [role="dialog"] .dates td:not(.disabled)',
+ allDates: '#ex1 [role="dialog"] .dates td',
+ jan12019Day: '#ex1 [role="dialog"] .dates td[data-date="2019-01-01"]',
+ jan22019Day: '#ex1 [role="dialog"] .dates td[data-date="2019-01-02"]',
+ todayButton: `#ex1 [role="dialog"] .dates [data-date="${todayDataDate}"]`,
+ monthYear: '#cb-dialog-label',
+ prevYear: '#ex1 [role="dialog"] button.prev-year',
+ prevMonth: '#ex1 [role="dialog"] button.prev-month',
+ nextMonth: '#ex1 [role="dialog"] button.next-month',
+ nextYear: '#ex1 [role="dialog"] button.next-year'
+};
+ex.allFocusableElementsInDialog = [
+ `#ex1 [role="dialog"] td[data-date="${todayDataDate}"]`,
+ '#ex1 [role="dialog"] button[value="cancel"]',
+ '#ex1 [role="dialog"] button[value="ok"]',
+ ex.prevYear,
+ ex.prevMonth,
+ ex.nextMonth,
+ ex.nextYear
+]
+
+const clickFirstOfMonth = async function (t) {
+ let today = new Date();
+ today.setUTCHours(0,0,0,0);
+
+ let firstOfMonth = new Date(today);
+ firstOfMonth.setDate(1);
+ let firstOfMonthString = today.toISOString().split('T')[0];
+
+ return (await t.context.queryElements(t, `[data-date=${firstOfMonthString}]`))[0].click();
+};
+
+const clickToday = async function (t) {
+ let today = new Date();
+ today.setUTCHours(0,0,0,0);
+ let todayString = today.toISOString().split('T')[0];
+ return (await t.context.queryElements(t, `[data-date=${todayString}]`))[0].click();
+};
+
+const setDateToJanFirst2019 = async function (t) {
+ await (await t.context.queryElements(t, ex.comboboxSelector))[0].click();
+ return t.context.session.executeScript(function () {
+ const inputSelector = arguments[0];
+ document.querySelector(inputSelector).value = '1/1/2019';
+ }, ex.comboboxSelector);
+};
+
+const focusMatchesElement = async function (t, selector) {
+ return t.context.session.wait(async function () {
+ return t.context.session.executeScript(function () {
+ selector = arguments[0];
+ return document.activeElement === document.querySelector(selector);
+ }, selector);
+ }, t.context.WaitTime);
+};
+
+// Attributes
+
+ariaTest('Combobox: has role', exampleFile, 'textbox-role', async (t) => {
+ await assertAriaRoles(t, 'ex1', 'combobox', 1, 'input');
+});
+
+ariaTest('Combobox: has aria-haspopup set to "dialog"', exampleFile, 'textbox-aria-haspopup', async (t) => {
+ await assertAttributeValues(t, ex.comboboxSelector, 'aria-haspopup', 'dialog');
+});
+
+ariaTest('Combobox: has aria-contorls set to "id-dialog-1"', exampleFile, 'textbox-aria-controls', async (t) => {
+ await assertAttributeValues(t, ex.comboboxSelector, 'aria-controls', 'cb-dialog-1');
+});
+
+ariaTest('Combobox: Initially aria-expanded set to "false"', exampleFile, 'textbox-aria-expanded-false', async (t) => {
+ await assertAttributeValues(t, ex.comboboxSelector, 'aria-expanded', 'false');
+});
+
+ariaTest('Combobox: aria-expanded set to "true" when dialog is open', exampleFile, 'textbox-aria-expanded-true', async (t) => {
+ // Open dialog box
+ await (await t.context.queryElements(t, ex.comboboxSelector))[0].sendKeys(Key.ENTER);
+ await assertAttributeValues(t, ex.comboboxSelector, 'aria-expanded', 'true');
+});
+
+
+
+// Button Tests
+
+ariaTest('Button: "aria-label" attribute', exampleFile, 'calendar-button-aria-label', async (t) => {
+ await assertAriaLabelExists(t, ex.buttonSelector);
+});
+
+ariaTest('Button: "tabindex" is set to -1', exampleFile, 'calendar-button-tabindex', async (t) => {
+ await assertAttributeValues(t, ex.buttonSelector, 'tabindex', '-1');
+});
+
+
+ariaTest('Button: has aria-haspopup set to "dialog"', exampleFile, 'textbox-aria-haspopup', async (t) => {
+ await assertAttributeValues(t, ex.buttonSelector, 'aria-haspopup', 'dialog');
+});
+
+ariaTest('Button: has aria-controls set to "id-dialog-1"', exampleFile, 'textbox-aria-controls', async (t) => {
+ await assertAttributeValues(t, ex.buttonSelector, 'aria-controls', 'cb-dialog-1');
+});
+
+ariaTest('Button: Initially aria-expanded set to "false"', exampleFile, 'calendar-button-aria-expanded-false', async (t) => {
+ await assertAttributeValues(t, ex.buttonSelector, 'aria-expanded', 'false');
+});
+
+ariaTest('Button: aria-expanded set to "true" when the dialog box is open', exampleFile, 'calendar-button-aria-expanded-true', async (t) => {
+ // Open dialog box
+ await (await t.context.queryElements(t, ex.buttonSelector))[0].sendKeys(Key.ENTER);
+ await assertAttributeValues(t, ex.buttonSelector, 'aria-expanded', 'true');
+});
+
+
+// Dialog Tests
+
+ariaTest('role="dialog" attribute on div', exampleFile, 'dialog-role', async (t) => {
+ await assertAriaRoles(t, 'ex1', 'dialog', 1, 'div');
+});
+
+ariaTest('aria-modal="true" on modal', exampleFile, 'dialog-aria-modal', async (t) => {
+ await assertAttributeValues(t, ex.dialogSelector, 'aria-modal', 'true');
+});
+
+ariaTest('aria-labelledby exist on dialog', exampleFile, 'dialog-aria-labelledby', async (t) => {
+ await assertAriaLabelledby(t, ex.dialogSelector);
+});
+
+ariaTest('aria-live="polite" on keyboard support message', exampleFile, 'dialog-aria-live', async (t) => {
+ await assertAttributeValues(t, ex.dialogMessageSelector, 'aria-live', 'polite');
+});
+
+ariaTest('"aria-label" exists on control buttons', exampleFile, 'calendar-navigation-button-aria-label', async (t) => {
+ await assertAriaLabelExists(t, ex.buttonSelector);
+});
+
+ariaTest('aria-live="polite" on dialog header', exampleFile, 'calendar-navigation-aria-live', async (t) => {
+ await assertAttributeValues(t, `${ex.dialogSelector} h2`, 'aria-live', 'polite');
+});
+
+ariaTest('grid role on table element', exampleFile, 'grid-role', async (t) => {
+ await assertAriaRoles(t, 'ex1', 'grid', 1, 'table');
+});
+
+ariaTest('aria-labelledby on grid element', exampleFile, 'grid-aria-labelledby', async (t) => {
+ await assertAriaLabelledby(t, ex.gridSelector);
+});
+
+
+ariaTest('Roving tab index on dates in gridcell', exampleFile, 'gridcell-tabindex', async (t) => {
+ let button = (await t.context.queryElements(t, ex.buttonSelector))[0];
+ await setDateToJanFirst2019(t);
+
+ await button.click();
+ await button.click();
+
+ let focusableButtons = await t.context.queryElements(t, ex.currentMonthDateButtons);
+ let allButtons = await t.context.queryElements(t, ex.allDates);
+
+ // test only one element has tabindex="0"
+ for (let tabableEl = 0; tabableEl < focusableButtons.length; tabableEl++) {
+ let dateSelected = await focusableButtons[tabableEl].getText();
+
+ for (let el = 0; el < allButtons.length; el++) {
+ let date = await allButtons[el].getText();
+ let disabled = (await allButtons[el].getAttribute('class')).includes('disabled');
+ let tabindex = dateSelected === date && !disabled ? '0' : '-1';
+ t.log('Tabindex: ' + tabindex + ' DS: ' + dateSelected + ' D: ' + date + ' Disabled: ' + disabled);
+
+ t.is(
+ await allButtons[el].getAttribute('tabindex'),
+ tabindex,
+ 'focus is on day ' + (tabableEl + 1) + ' therefore the button number ' +
+ el + ' should have tab index set to: ' + tabindex
+ );
+ }
+
+ // Send the tabindex="0" element the appropriate key to switch focus to the next element
+ await focusableButtons[tabableEl].sendKeys(Key.ARROW_RIGHT);
+ }
+});
+
+ariaTest('aria-selected on selected date', exampleFile, 'gridcell-aria-selected', async (t) => {
+ let button = (await t.context.queryElements(t, ex.buttonSelector))[0];
+
+ await button.click();
+ await assertAttributeDNE(t, ex.allDates, 'aria-selected');
+
+ await setDateToJanFirst2019(t);
+ await button.click();
+ await assertAttributeValues(t, ex.jan12019Day, 'aria-selected', 'true');
+
+ let selectedButtons = await t.context.queryElements(t, `${ex.allDates}[aria-selected="true"]`);
+
+ t.is(
+ selectedButtons.length,
+ 1,
+ 'after setting date in box, only one button should have aria-selected'
+ );
+
+ await (await t.context.queryElements(t, ex.jan22019Day))[0].click();
+ await button.click();
+ await assertAttributeValues(t, ex.jan22019Day, 'aria-selected', 'true');
+
+ selectedButtons = await t.context.queryElements(t, `${ex.allDates}[aria-selected="true"]`);
+
+ t.is(
+ selectedButtons.length,
+ 1,
+ 'after clicking a date and re-opening datepicker, only one button should have aria-selected'
+ );
+
+});
+
+// Keyboard
+
+
+ariaTest('DOWN ARROW, ALT plus DOWN ARROW and ENTER to open datepicker', exampleFile, 'combobox-down-arrow-enter', async (t) => {
+ let combobox = (await t.context.queryElements(t, ex.comboboxSelector))[0];
+ let dialog = (await t.context.queryElements(t, ex.dialogSelector))[0];
+ let cancel = (await t.context.queryElements(t, ex.cancelSelector))[0];
+
+ // Test ENTER key
+ await combobox.sendKeys(Key.ENTER);
+
+ t.not(
+ await dialog.getCssValue('display'),
+ 'none',
+ 'After sending ENTER to the combobox, the calendar dialog should open'
+ );
+
+ // Close dialog
+ await cancel.sendKeys(Key.ENTER);
+
+ t.not(
+ await dialog.getCssValue('display'),
+ 'block',
+ 'After sending ESCAPE to the dialog, the calendar dialog should close'
+ );
+
+ // Test DOWN ARROW key
+ await combobox.sendKeys(Key.ARROW_DOWN);
+
+ t.not(
+ await dialog.getCssValue('display'),
+ 'none',
+ 'After sending DOWN ARROW to the combobox, the calendar dialog should open'
+ );
+
+ // Close dialog
+ await cancel.sendKeys(Key.ENTER);
+
+ t.not(
+ await dialog.getCssValue('display'),
+ 'block',
+ 'After sending ESCAPE to the dialog, the calendar dialog should close'
+ );
+
+ // Test ALT + DOWN ARROW key
+ await combobox.sendKeys(Key.ALT, Key.ARROW_DOWN);
+
+ t.not(
+ await dialog.getCssValue('display'),
+ 'none',
+ 'After sending DOWN ARROW to the combobox, the calendar dialog should open'
+ );
+
+ // Close dialog
+ await cancel.sendKeys(Key.ENTER);
+
+ t.not(
+ await dialog.getCssValue('display'),
+ 'block',
+ 'After sending ESCAPE to the dialog, the calendar dialog should close'
+ );
+
+});
+
+ariaTest('ENTER to open datepicker', exampleFile, 'button-space-return', async (t) => {
+ let chooseDateButton = (await t.context.queryElements(t, ex.buttonSelector))[0];
+ await chooseDateButton.sendKeys(Key.ENTER);
+
+ t.not(
+ await (await t.context.queryElements(t, ex.dialogSelector))[0].getCssValue('display'),
+ 'none',
+ 'After sending ENTER to the "choose date" button, the calendar dialog should open'
+ );
+});
+
+ariaTest('SPACE to open datepicker', exampleFile, 'button-space-return', async (t) => {
+ let chooseDateButton = (await t.context.queryElements(t, ex.buttonSelector))[0];
+ await chooseDateButton.sendKeys(' ');
+
+ t.not(
+ await (await t.context.queryElements(t, ex.dialogSelector))[0].getCssValue('display'),
+ 'none',
+ 'After sending SPACE to the "choose date" button, the calendar dialog should open'
+ );
+});
+
+ariaTest('Sending key ESC when focus is in dialog closes dialog', exampleFile, 'dialog-esc', async (t) => {
+ let chooseDateButton = (await t.context.queryElements(t, ex.buttonSelector))[0];
+
+ for (let i = 0; i < ex.allFocusableElementsInDialog.length; i++) {
+
+ await chooseDateButton.sendKeys(Key.ENTER);
+ let el = (await t.context.queryElements(t, ex.allFocusableElementsInDialog[i]))[0];
+ await el.sendKeys(Key.ESCAPE);
+
+ t.is(
+ await (await t.context.queryElements(t, ex.dialogSelector))[0].getCssValue('display'),
+ 'none',
+ 'After sending ESC to element "' + ex.allFocusableElementsInDialog[i] + '" in the dialog, the calendar dialog should close'
+ );
+
+ t.is(
+ await (await t.context.queryElements(t, ex.comboboxSelector))[0].getAttribute('value'),
+ '',
+ 'After sending ESC to element "' + ex.allFocusableElementsInDialog[i] + '" in the dialog, no date should be selected'
+ );
+ }
+});
+
+ariaTest('ENTER on previous year or month and SPACE on next year or month changes the year or month', exampleFile, 'month-year-button-space-return', async (t) => {
+ await (await t.context.queryElements(t, ex.buttonSelector))[0].sendKeys(Key.ENTER);
+
+ let monthYear = (await t.context.queryElements(t, ex.monthYear))[0];
+ let originalMonthYear = await monthYear.getText();
+
+ for (let yearOrMonth of ['Year', 'Month']) {
+ let yearOrMonthLower = yearOrMonth.toLowerCase();
+
+ // enter on previous year or month should change the monthYear text
+ await (await t.context.queryElements(t, ex[`prev${yearOrMonth}`]))[0].sendKeys(Key.ENTER);
+
+ t.not(
+ await monthYear.getText(),
+ originalMonthYear,
+ `After sending ENTER on the "previous ${yearOrMonthLower}" button, the month and year text should be not be ${originalMonthYear}`
+ );
+
+ // space on next year or month should change it back to the original
+ await (await t.context.queryElements(t, ex[`next${yearOrMonth}`]))[0].sendKeys(Key.SPACE);
+
+ t.is(
+ await monthYear.getText(),
+ originalMonthYear,
+ `After sending SPACE on the "next ${yearOrMonthLower}" button, the month and year text should be ${originalMonthYear}`
+ );
+ }
+});
+
+ariaTest('Tab should go through all tabbable items, then repear', exampleFile, 'dialog-tab', async (t) => {
+ await (await t.context.queryElements(t, ex.buttonSelector))[0].sendKeys(Key.ENTER);
+
+ for (let itemSelector of ex.allFocusableElementsInDialog) {
+ t.true(
+ await focusMatchesElement(t, itemSelector),
+ 'Focus should be on: ' + itemSelector
+ );
+
+ await (await t.context.queryElements(t, itemSelector))[0].sendKeys(Key.TAB);
+ }
+
+ t.true(
+ await focusMatchesElement(t, ex.allFocusableElementsInDialog[0]),
+ 'After tabbing through all items, focus should return to: ' + ex.allFocusableElementsInDialog[0]
+ );
+});
+
+ariaTest('Shift-tab should move focus backwards', exampleFile, 'dialog-shift-tab', async (t) => {
+ t.plan(7);
+
+ await (await t.context.queryElements(t, ex.buttonSelector))[0].sendKeys(Key.ENTER);
+
+ await (await t.context.queryElements(t, ex.allFocusableElementsInDialog[0]))[0]
+ .sendKeys(Key.chord(Key.SHIFT, Key.TAB));
+
+ let lastIndex = ex.allFocusableElementsInDialog.length - 1;
+ for (let i = lastIndex; i >= 0; i--) {
+ t.true(
+ await focusMatchesElement(t, ex.allFocusableElementsInDialog[i]),
+ 'Focus should be on: ' + ex.allFocusableElementsInDialog[i]
+ );
+
+ await (await t.context.queryElements(t, ex.allFocusableElementsInDialog[i]))[0]
+ .sendKeys(Key.chord(Key.SHIFT, Key.TAB));
+ }
+});
+
+// TODO(zcorpan): Missing tests. Either mark as "test-not-required" or write the test.
+ariaTest.failing(`Test not implemented: grid-space`, exampleFile, 'grid-space', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-return`, exampleFile, 'grid-return', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-up-arrow`, exampleFile, 'grid-up-arrow', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-down-arrow`, exampleFile, 'grid-down-arrow', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-right-arrow`, exampleFile, 'grid-right-arrow', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-left-arrow`, exampleFile, 'grid-left-arrow', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-home`, exampleFile, 'grid-home', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-end`, exampleFile, 'grid-end', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-pageup`, exampleFile, 'grid-pageup', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-shift-pageup`, exampleFile, 'grid-shift-pageup', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-pagedown`, exampleFile, 'grid-pagedown', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: grid-shift-pagedown`, exampleFile, 'grid-shift-pagedown', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: okay-cancel-button-space-return`, exampleFile, 'okay-cancel-button-space-return', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: textbox-aria-autocomplete`, exampleFile, 'textbox-aria-autocomplete', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: textbox-aria-live`, exampleFile, 'textbox-aria-live', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: calendar-button-aria-haspopup`, exampleFile, 'calendar-button-aria-haspopup', async (t) => {
+ t.fail();
+});
+
+ariaTest.failing(`Test not implemented: calendar-button-aria-controls`, exampleFile, 'calendar-button-aria-controls', async (t) => {
+ t.fail();
+});