diff --git a/lib/index.html b/lib/index.html index f398211..e06beff 100755 --- a/lib/index.html +++ b/lib/index.html @@ -2,7 +2,7 @@ - + diff --git a/lib/markup/accordian.html b/lib/markup/accordian.html new file mode 100644 index 0000000..395676b --- /dev/null +++ b/lib/markup/accordian.html @@ -0,0 +1,112 @@ + + + +
+

+ +

+
+
+
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+
+
+
+

+ +

+ +

+ +

+ +
+ diff --git a/lib/markup/alert-dialogue.html b/lib/markup/alert-dialogue.html new file mode 100644 index 0000000..92f45fe --- /dev/null +++ b/lib/markup/alert-dialogue.html @@ -0,0 +1,29 @@ + + + + + + + + +
+ +
+ diff --git a/lib/markup/alert.html b/lib/markup/alert.html new file mode 100644 index 0000000..ed75662 --- /dev/null +++ b/lib/markup/alert.html @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/lib/markup/breadcrumb.html b/lib/markup/breadcrumb.html new file mode 100644 index 0000000..b4c621a --- /dev/null +++ b/lib/markup/breadcrumb.html @@ -0,0 +1,20 @@ + + + + + diff --git a/lib/markup/button.html b/lib/markup/button.html new file mode 100644 index 0000000..a2549c8 --- /dev/null +++ b/lib/markup/button.html @@ -0,0 +1,30 @@ + + + +

This Print action button uses a div element.

+
Print Page
+ +

This Mute toggle button uses an a element.

+ + Mute + + + + + diff --git a/lib/markup/carousel.html b/lib/markup/carousel.html new file mode 100644 index 0000000..91d7d78 --- /dev/null +++ b/lib/markup/carousel.html @@ -0,0 +1,160 @@ + + + + + + +
+ diff --git a/lib/markup/checkbox.html b/lib/markup/checkbox.html new file mode 100644 index 0000000..e877154 --- /dev/null +++ b/lib/markup/checkbox.html @@ -0,0 +1,22 @@ + + + +
+ Sandwich Condiments + + +
+ diff --git a/lib/markup/combobox.html b/lib/markup/combobox.html new file mode 100644 index 0000000..f877955 --- /dev/null +++ b/lib/markup/combobox.html @@ -0,0 +1,73 @@ + + + + +
+
+ + +
+ +
+ diff --git a/lib/markup/date-picker.html b/lib/markup/date-picker.html new file mode 100644 index 0000000..a81527c --- /dev/null +++ b/lib/markup/date-picker.html @@ -0,0 +1,116 @@ + + + +
+
+ + +
+ + (date format: mm/dd/yyyy) + +
+
+ + +
+ diff --git a/lib/markup/modal-dialogue.html b/lib/markup/modal-dialogue.html new file mode 100644 index 0000000..cd1ee14 --- /dev/null +++ b/lib/markup/modal-dialogue.html @@ -0,0 +1,122 @@ + + + + +
+ + + + + + + + + +
+ diff --git a/lib/styles/accordian.css b/lib/styles/accordian.css new file mode 100644 index 0000000..51a213a --- /dev/null +++ b/lib/styles/accordian.css @@ -0,0 +1,124 @@ +.accordion { + margin: 0; + padding: 0; + border: 2px solid hsl(0deg 0% 52%); + border-radius: 7px; + width: 20em; + } + + .accordion h3 { + margin: 0; + padding: 0; + } + + .accordion:focus-within { + border-color: hsl(216deg 94% 43%); + } + + .accordion:focus-within h3 { + background-color: hsl(0deg 0% 97%); + } + + .accordion > * + * { + border-top: 1px solid hsl(0deg 0% 52%); + } + + .accordion-trigger { + background: none; + color: hsl(0deg 0% 13%); + display: block; + font-size: 1rem; + font-weight: normal; + margin: 0; + padding: 1em 1.5em; + position: relative; + text-align: left; + width: 100%; + outline: none; + } + + .accordion-trigger:focus, + .accordion-trigger:hover { + background: hsl(216deg 94% 94%); + } + + .accordion-trigger:focus { + outline: 4px solid transparent; + } + + .accordion > *:first-child .accordion-trigger, + .accordion > *:first-child { + border-radius: 5px 5px 0 0; + } + + .accordion > *:last-child .accordion-trigger, + .accordion > *:last-child { + border-radius: 0 0 5px 5px; + } + + button { + border-style: none; + } + + .accordion button::-moz-focus-inner { + border: 0; + } + + .accordion-title { + display: block; + pointer-events: none; + border: transparent 2px solid; + border-radius: 5px; + padding: 0.25em; + outline: none; + } + + .accordion-trigger:focus .accordion-title { + border-color: hsl(216deg 94% 43%); + } + + .accordion-icon { + border: solid currentcolor; + border-width: 0 2px 2px 0; + height: 0.5rem; + pointer-events: none; + position: absolute; + right: 2em; + top: 50%; + transform: translateY(-60%) rotate(45deg); + width: 0.5rem; + } + + .accordion-trigger:focus .accordion-icon, + .accordion-trigger:hover .accordion-icon { + border-color: hsl(216deg 94% 43%); + } + + .accordion-trigger[aria-expanded="true"] .accordion-icon { + transform: translateY(-50%) rotate(-135deg); + } + + .accordion-panel { + margin: 0; + padding: 1em 1.5em; + } + + /* For Edge bug https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/4806035/ */ + .accordion-panel[hidden] { + display: none; + } + + fieldset { + border: 0; + margin: 0; + padding: 0; + } + + input { + border: 1px solid hsl(0deg 0% 42%); + border-radius: 0.3em; + display: block; + font-size: inherit; + padding: 0.3em 0.5em; + } + \ No newline at end of file diff --git a/lib/styles/alert-dialogue.css b/lib/styles/alert-dialogue.css new file mode 100644 index 0000000..7acc0b9 --- /dev/null +++ b/lib/styles/alert-dialogue.css @@ -0,0 +1,134 @@ +.hidden { + display: none; + } + + [role="alertdialog"] { + box-sizing: border-box; + padding: 15px; + border: 1px solid #000; + background-color: #fff; + min-height: 100vh; + } + + @media screen and (min-width: 640px) { + [role="alertdialog"] { + position: absolute; + top: 2rem; + left: 50vw; /* move to the middle of the screen (assumes relative parent is the body/viewport) */ + transform: translateX( + -50% + ); /* move backwards 50% of this element's width */ + + min-width: calc(640px - (15px * 2)); /* == breakpoint - left+right margin */ + min-height: auto; + box-shadow: + 0 19px 38px rgb(0 0 0 / 12%), + 0 15px 12px rgb(0 0 0 / 22%); + } + } + + .dialog_label { + text-align: center; + } + + .dialog_form_actions { + text-align: right; + padding: 0 20px 20px; + } + + .dialog_desc { + padding: 10px 20px; + } + + /* native element uses the ::backdrop pseudo-element */ + + /* dialog::backdrop, */ + .dialog-backdrop { + display: none; + position: fixed; + overflow-y: auto; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + @media screen and (min-width: 640px) { + .dialog-backdrop { + background: rgb(0 0 0 / 30%); + } + } + + .dialog-backdrop.active { + display: block; + } + + .no-scroll { + overflow-y: auto !important; + } + + /* this is added to the body when a dialog is open */ + .has-dialog { + overflow: hidden; + } + + /* styling for alertdialog example */ + .notes { + display: block; + font-size: 1rem; + line-height: 1.3; + min-width: 400px; + max-width: 100%; + width: 33%; + } + + .visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: auto; + margin: 0; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + white-space: nowrap; + } + + #ex_alertdialog [aria-disabled="true"] { + opacity: 0.4; + } + + #notes_save { + display: inline-flex; + align-items: center; + gap: 0.5rem; + } + + #notes_save svg { + display: block; + width: 0.75rem; + } + + #notes_save .icon { + display: none; + } + + @keyframes rotate { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } + + #notes_save.loading .spinner { + display: block; + animation: rotate 2s linear infinite; + } + + #notes_save.saved .check { + display: block; + } + \ No newline at end of file diff --git a/lib/styles/alert.css b/lib/styles/alert.css new file mode 100644 index 0000000..740ec34 --- /dev/null +++ b/lib/styles/alert.css @@ -0,0 +1,10 @@ +[role="alert"] { + padding: 10px; + border: 2px solid hsl(206deg 74% 54%); + border-radius: 4px; + background: hsl(206deg 74% 90%); + } + +[role="alert"]:empty { + display: none; +} diff --git a/lib/styles/breadcrumb.css b/lib/styles/breadcrumb.css new file mode 100644 index 0000000..6cf939d --- /dev/null +++ b/lib/styles/breadcrumb.css @@ -0,0 +1,32 @@ +nav.breadcrumb { + padding: 0.8em 1em; + border: 1px solid hsl(0deg 0% 90%); + border-radius: 4px; + background: hsl(300deg 14% 97%); + } + + nav.breadcrumb ol { + margin: 0; + padding-left: 0; + list-style: none; + } + + nav.breadcrumb li { + display: inline; + } + + nav.breadcrumb li + li::before { + display: inline-block; + margin: 0 0.25em; + transform: rotate(15deg); + border-right: 0.1em solid currentcolor; + height: 0.8em; + content: ""; + } + + nav.breadcrumb [aria-current="page"] { + color: #000; + font-weight: 700; + text-decoration: none; + } + \ No newline at end of file diff --git a/lib/styles/button.css b/lib/styles/button.css new file mode 100644 index 0000000..2b73f47 --- /dev/null +++ b/lib/styles/button.css @@ -0,0 +1,115 @@ +[role="button"] { + display: inline-block; + position: relative; + padding: 0.4em 0.7em; + border: 1px solid hsl(213deg 71% 49%); + border-radius: 5px; + box-shadow: 0 1px 2px hsl(216deg 27% 55%); + color: #fff; + text-shadow: 0 -1px 1px hsl(216deg 27% 25%); + background-color: hsl(216deg 82% 51%); + background-image: linear-gradient( + to bottom, + hsl(216deg 82% 53%), + hsl(216deg 82% 47%) + ); + } + + [role="button"]:hover { + border-color: hsl(213deg 71% 29%); + background-color: hsl(216deg 82% 31%); + background-image: linear-gradient( + to bottom, + hsl(216deg 82% 33%), + hsl(216deg 82% 27%) + ); + cursor: default; + } + + [role="button"]:focus { + outline: none; + } + + [role="button"]:focus::before { + position: absolute; + + /* button border width - outline width - offset */ + top: calc(-1px - 3px - 3px); + right: calc(-1px - 3px - 3px); + bottom: calc(-1px - 3px - 3px); + left: calc(-1px - 3px - 3px); + border: 3px solid hsl(213deg 71% 49%); + + /* button border radius + outline width + offset */ + border-radius: calc(5px + 3px + 3px); + content: ""; + } + + [role="button"]:active { + border-color: hsl(213deg 71% 49%); + background-color: hsl(216deg 82% 31%); + background-image: linear-gradient( + to bottom, + hsl(216deg 82% 53%), + hsl(216deg 82% 47%) + ); + box-shadow: inset 0 3px 5px 1px hsl(216deg 82% 30%); + } + + [role="button"][aria-pressed] { + border-color: hsl(261deg 71% 49%); + box-shadow: 0 1px 2px hsl(261deg 27% 55%); + text-shadow: 0 -1px 1px hsl(261deg 27% 25%); + background-color: hsl(261deg 82% 51%); + background-image: linear-gradient( + to bottom, + hsl(261deg 82% 53%), + hsl(261deg 82% 47%) + ); + } + + [role="button"][aria-pressed]:hover { + border-color: hsl(261deg 71% 29%); + background-color: hsl(261deg 82% 31%); + background-image: linear-gradient( + to bottom, + hsl(261deg 82% 33%), + hsl(261deg 82% 27%) + ); + } + + [role="button"][aria-pressed="true"] { + padding-top: 0.5em; + padding-bottom: 0.3em; + border-color: hsl(261deg 71% 49%); + background-color: hsl(261deg 82% 31%); + background-image: linear-gradient( + to bottom, + hsl(261deg 82% 63%), + hsl(261deg 82% 57%) + ); + box-shadow: inset 0 3px 5px 1px hsl(261deg 82% 30%); + } + + [role="button"][aria-pressed="true"]:hover { + border-color: hsl(261deg 71% 49%); + background-color: hsl(261deg 82% 31%); + background-image: linear-gradient( + to bottom, + hsl(261deg 82% 43%), + hsl(261deg 82% 37%) + ); + box-shadow: inset 0 3px 5px 1px hsl(261deg 82% 20%); + } + + [role="button"][aria-pressed]:focus::before { + border-color: hsl(261deg 71% 49%); + } + + [role="button"] svg { + margin: 0.15em auto -0.15em; + height: 1em; + width: 1em; + pointer-events: none; + } + \ No newline at end of file diff --git a/lib/styles/carousel.css b/lib/styles/carousel.css new file mode 100644 index 0000000..b582a25 --- /dev/null +++ b/lib/styles/carousel.css @@ -0,0 +1,286 @@ +/* .carousel */ + +img.reload { + padding: 0.25em; + display: block-inline; + position: relative; + top: 6px; + height: 0.9em; + } + + .carousel { + background-color: #eee; + max-width: 900px; + } + + .carousel .carousel-inner { + position: relative; + } + + .carousel .carousel-items { + padding: 5px; + } + + .carousel .carousel-items.focus { + padding: 2px; + border: solid 3px #005a9c; + } + + .carousel .carousel-item { + display: none; + max-height: 400px; + max-width: 900px; + position: relative; + overflow: hidden; + width: 100%; + } + + .carousel .carousel-item.active { + display: block; + } + + /* More like bootstrap, less accessible */ + + .carousel .carousel-item .carousel-image a img { + height: 100%; + width: 100%; + } + + .carousel .carousel-item .carousel-caption a { + cursor: pointer; + text-decoration: underline; + color: #fff; + font-weight: 600; + } + + .carousel .carousel-item .carousel-caption a, + .carousel .carousel-item .carousel-caption span.contrast { + display: inline-block; + margin: 0; + padding: 6px; + background-color: rgb(0 0 0 / 65%); + border-radius: 5px; + border: 0 solid transparent; + } + + .carousel-moreaccessible .carousel-items .carousel-image a { + display: block; + margin: 0; + padding: 5px; + text-decoration: none; + border: none; + } + + .carousel-moreaccessible .carousel-item .carousel-caption a { + display: inline-block; + margin: 0; + padding: 6px; + color: black; + background-color: transparent; + border: none; + border-radius: 5px; + } + + .carousel-moreaccessible .carousel-item .carousel-caption span.contrast, + .carousel-moreaccessible .carousel-item .carousel-caption span.contrast:hover { + background-color: transparent; + } + + .carousel .carousel-item .carousel-caption a:hover, + .carousel .carousel-item .carousel-caption span.contrast:hover { + background-color: rgb(0 0 0 / 100%); + } + + .carousel .carousel-item .carousel-caption a:focus { + padding: 4px; + border: 2px solid #fff; + background-color: rgb(0 0 0 / 100%); + outline: none; + border-width: 2px solid #fff; + color: #fff; + } + + .carousel .carousel-item .carousel-caption p { + font-size: 1em; + line-height: 1.5; + margin-bottom: 0; + } + + .carousel .carousel-item .carousel-caption { + position: absolute; + right: 15%; + bottom: 0; + left: 15%; + padding-top: 20px; + padding-bottom: 20px; + color: #fff; + text-align: center; + } + + /* Shared CSS for Pause, Previous and Next Buttons */ + + .carousel .controls { + box-sizing: border-box; + position: absolute; + top: 1em; + z-index: 10; + display: flex; + width: 100%; + padding: 0.25em 1.25em 0; + } + + .carousel .controls button { + position: absolute; + z-index: 10; + flex: 0 0 auto; + margin: 0; + padding: 0; + border: none; + background: transparent; + outline: none; + } + + .carousel .controls button.previous { + right: 70px; + } + + .carousel .controls button.next { + right: 18px; + } + + /* SVG Controls */ + + .carousel .controls svg .background { + stroke: black; + fill: black; + stroke-width: 1px; + opacity: 0.6; + } + + .carousel .controls svg .border { + fill: transparent; + stroke: transparent; + stroke-width: 2px; + } + + .carousel .controls svg .pause { + stroke-width: 4; + fill: transparent; + stroke: transparent; + } + + .carousel .controls svg .play { + stroke-width: 1; + fill: transparent; + stroke: transparent; + } + + .carousel .controls .pause svg .pause { + fill: white; + stroke: white; + } + + .carousel .controls .play svg .play { + fill: white; + stroke: white; + } + + .carousel .controls svg polygon { + fill: white; + stroke: white; + } + + .carousel .controls button:focus svg .background, + .carousel .controls button:hover svg .background, + .carousel .controls button:hover svg .border { + fill: #005a9c; + stroke: #005a9c; + opacity: 1; + } + + .carousel .controls button:focus svg .border { + stroke: white; + } + + /* More accessible carousel styles, with caption and controls above/below image */ + + .carousel-moreaccessible { + padding: 0; + margin: 0; + position: relative; + border: #eee solid 4px; + border-radius: 5px; + } + + /* Shared CSS for Pause and Tab Controls */ + + .carousel-moreaccessible .controls { + position: relative; + top: 0; + left: 0; + padding: 0.25em 0.25em 0; + } + + .carousel.carousel-moreaccessible .controls { + position: static; + height: 36px; + } + + .carousel.carousel-moreaccessible .controls button.previous { + right: 60px; + } + + .carousel.carousel-moreaccessible .controls button.next { + right: 6px; + } + + .carousel-moreaccessible .carousel-items, + .carousel-moreaccessible .carousel-items.focus { + padding: 0; + border: none; + } + + .carousel-moreaccessible .carousel-items.focus .carousel-image a { + padding: 2px; + border: 3px solid #005a9c; + } + + /* More accessible caption styling */ + + .carousel-moreaccessible .carousel-item { + padding: 0; + margin: 0; + max-height: none; + } + + .carousel-moreaccessible .carousel-item .carousel-caption { + position: static; + padding: 0; + margin: 0; + height: 60px; + color: black; + } + + .carousel-moreaccessible .carousel-item .carousel-caption p { + padding: 0; + margin: 0; + } + + .carousel-moreaccessible .carousel-item .carousel-caption h3 { + font-size: 1.1em; + padding: 0; + margin: 0; + } + + .carousel-moreaccessible .carousel-item .carousel-caption a:hover { + background-color: rgb(0 0 0 / 20%); + } + + .carousel-moreaccessible .carousel-item .carousel-caption a:focus { + padding: 4px; + border: 2px solid #005a9c; + background-color: transparent; + color: black; + outline: none; + } + \ No newline at end of file diff --git a/lib/styles/checkbox.css b/lib/styles/checkbox.css new file mode 100644 index 0000000..f677f15 --- /dev/null +++ b/lib/styles/checkbox.css @@ -0,0 +1,63 @@ +.checkbox-mixed ul.checkboxes { + list-style: none; + margin: 0; + padding: 0; + } + + .checkbox-mixed ul.checkboxes li { + margin: 0; + padding: 0; + padding-left: 15px; + } + + .checkbox-mixed label { + margin: 1px; + padding: 4px; + } + + .checkbox-mixed [role="checkbox"] { + display: inline-block; + padding: 4px; + cursor: pointer; + } + + .checkbox-mixed [role="checkbox"]::before { + position: relative; + top: 1px; + content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='16' width='16' style='forced-color-adjust: auto;'%3E%3Crect x='2' y='2' height='13' width='13' rx='2' stroke='currentcolor' stroke-width='1' fill-opacity='0' /%3E%3C/svg%3E"); + } + + .checkbox-mixed [role="checkbox"][aria-checked="true"]::before { + position: relative; + top: 1px; + content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='16' width='16' style='forced-color-adjust: auto;'%3E%3Crect x='2' y='2' height='13' width='13' rx='2' stroke='currentcolor' stroke-width='1' fill-opacity='0' /%3E%3Cpolyline points='4,8 7,12 12,5' fill='none' stroke='currentcolor' stroke-width='2' /%3E%3C/svg%3E"); + } + + .checkbox-mixed [role="checkbox"][aria-checked="mixed"]::before { + position: relative; + top: 1px; + content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='16' width='16' style='forced-color-adjust: auto;'%3E%3Crect x='2' y='2' height='13' width='13' rx='2' stroke='currentcolor' stroke-width='1' fill-opacity='0' /%3E%3Cline x1='5' y1='5' x2='12' y2='12' stroke='currentcolor' stroke-width='2' /%3E%3C/svg%3E"); + } + + .checkbox-mixed input:focus, + .checkbox-mixed [role="checkbox"] { + outline: none; + } + + .checkbox-mixed [role="checkbox"]:focus, + .checkbox-mixed [role="checkbox"]:hover { + padding: 2px; + border: 2px solid #005a9c; + border-radius: 5px; + background-color: #def; + } + + .checkbox-mixed label.focus, + .checkbox-mixed label:hover { + padding: 2px; + border: 2px solid #005a9c; + background-color: #def; + border-radius: 5px; + cursor: pointer; + } + \ No newline at end of file diff --git a/lib/styles/combobox.css b/lib/styles/combobox.css new file mode 100644 index 0000000..2e164b1 --- /dev/null +++ b/lib/styles/combobox.css @@ -0,0 +1,100 @@ +.combobox-list { + position: relative; + } + + .combobox .group { + display: inline-flex; + padding: 4px; + cursor: pointer; + } + + .combobox input, + .combobox button { + background-color: white; + color: black; + box-sizing: border-box; + height: 30px; + padding: 0; + margin: 0; + vertical-align: bottom; + border: 1px solid gray; + position: relative; + cursor: pointer; + } + + .combobox input { + width: 150px; + border-right: none; + outline: none; + font-size: 87.5%; + padding: 1px 3px; + } + + .combobox button { + width: 19px; + border-left: none; + outline: none; + color: rgb(0 90 156); + } + + .combobox button[aria-expanded="true"] svg { + transform: rotate(180deg) translate(0, -3px); + } + + ul[role="listbox"] { + margin: 0; + padding: 0; + position: absolute; + left: 4px; + top: 34px; + list-style: none; + background-color: white; + display: none; + box-sizing: border-box; + border: 2px currentcolor solid; + max-height: 250px; + width: 168px; + overflow: scroll; + overflow-x: hidden; + font-size: 87.5%; + cursor: pointer; + } + + ul[role="listbox"] li[role="option"] { + margin: 0; + display: block; + padding-left: 3px; + padding-top: 2px; + padding-bottom: 2px; + } + + /* focus and hover styling */ + + .combobox .group.focus, + .combobox .group:hover { + padding: 2px; + border: 2px solid currentcolor; + border-radius: 4px; + } + + .combobox .group.focus polygon, + .combobox .group:hover polygon { + fill-opacity: 1; + } + + .combobox .group.focus input, + .combobox .group.focus button, + .combobox .group input:hover, + .combobox .group button:hover { + background-color: #def; + } + + [role="listbox"].focus [role="option"][aria-selected="true"], + [role="listbox"] [role="option"]:hover { + background-color: #def; + padding-top: 0; + padding-bottom: 0; + border-top: 2px solid currentcolor; + border-bottom: 2px solid currentcolor; + } + \ No newline at end of file diff --git a/lib/styles/date-picker.css b/lib/styles/date-picker.css new file mode 100644 index 0000000..0c02491 --- /dev/null +++ b/lib/styles/date-picker.css @@ -0,0 +1,254 @@ +.sr-only { + position: absolute; + top: -2000em; + left: -3000em; +} + +.datepicker { + margin-top: 1em; + position: relative; +} + +.datepicker .group { + display: inline-block; + position: relative; + width: 13em; +} + +.datepicker label { + display: block; +} + +.datepicker input { + padding: 0; + margin: 0; + height: 1.5em; + background-color: white; + color: black; + border: 1px solid gray; +} + +.datepicker button.icon { + position: relative; + top: 0.25em; + margin: 0; + padding: 4px; + border: 0 solid #005a9c; + background-color: white; + border-radius: 5px; +} + +.datepicker .desc { + position: absolute; + left: 0; + top: 2em; +} + +.datepicker .fa-calendar-alt { + color: hsl(216deg 89% 51%); +} + +.datepicker button.icon:focus { + outline: none; + padding: 2px; + border-width: 2px; + background-color: #def; +} + +.datepicker input:focus { + background-color: #def; + outline: 2px solid #005a9c; + outline-offset: 1px; +} + +.datepicker-dialog { + position: absolute; + width: 320px; + clear: both; + border: 3px solid hsl(216deg 80% 51%); + margin-top: 0.15em; + border-radius: 5px; + padding: 0; + background-color: #fff; +} + +.datepicker-dialog .header { + cursor: default; + background-color: hsl(216deg 80% 51%); + padding: 7px; + font-weight: bold; + text-transform: uppercase; + color: white; + display: flex; + justify-content: space-around; +} + +.datepicker-dialog h2 { + margin: 0; + padding: 0; + display: inline-block; + font-size: 1em; + color: white; + text-transform: none; + font-weight: bold; + border: none; +} + +.datepicker-dialog button { + border-style: none; + background: transparent; +} + +.datepicker-dialog button::-moz-focus-inner { + border: 0; +} + +.datepicker-dialog .dates { + width: 320px; +} + +.datepicker-dialog .prev-year, +.datepicker-dialog .prev-month, +.datepicker-dialog .next-month, +.datepicker-dialog .next-year { + padding: 4px; + width: 24px; + height: 24px; + color: white; +} + +.datepicker-dialog .prev-year:focus, +.datepicker-dialog .prev-month:focus, +.datepicker-dialog .next-month:focus, +.datepicker-dialog .next-year:focus { + padding: 2px; + border: 2px solid white; + border-radius: 4px; + outline: 0; +} + +.datepicker-dialog .prev-year:hover, +.datepicker-dialog .prev-month:hover, +.datepicker-dialog .next-month:hover, +.datepicker-dialog .next-year:hover { + padding: 3px; + border: 1px solid white; + border-radius: 4px; +} + +.datepicker-dialog .dialog-ok-cancel-group { + text-align: right; + margin-top: 1em; + margin-bottom: 1em; + margin-right: 1em; +} + +.datepicker-dialog .dialog-ok-cancel-group button { + padding: 6px; + margin-left: 1em; + width: 5em; + background-color: hsl(216deg 80% 92%); + font-size: 0.85em; + color: black; + outline: none; + border-radius: 5px; +} + +.datepicker-dialog .dialog-button:focus { + padding: 4px; + border: 2px solid black; +} + +.datepicker-dialog .dialog-button:hover { + padding: 5px; + border: 1px solid black; +} + +.datepicker-dialog .fa-calendar-alt { + color: hsl(216deg 89% 51%); +} + +.datepicker-dialog .month-year { + display: inline-block; + width: 12em; + text-align: center; +} + +.datepicker-dialog table.dates { + padding-left: 1em; + padding-right: 1em; + padding-top: 1em; + border: none; + border-collapse: separate; +} + +.datepicker-dialog table.dates th, +.datepicker-dialog table.dates td { + text-align: center; + background: white; + color: black; + border: none; +} + +.datepicker-dialog table.dates tr { + border: 1px solid black; +} + +.datepicker-dialog table.dates td { + padding: 3px; + margin: 0; + line-height: inherit; + height: 40px; + width: 40px; + border-radius: 5px; + font-size: 15px; + background: #eee; +} + +.datepicker-dialog table.dates td.disabled { + padding: 2px; + border: none; + height: 41px; + width: 41px; +} + +.datepicker-dialog table.dates td:focus, +.datepicker-dialog table.dates td:hover { + padding: 0; + background-color: hsl(216deg 80% 92%); + color: black; +} + +.datepicker-dialog table.dates td:focus { + padding: 1px; + border: 2px solid rgb(100 100 100); + outline: 0; +} + +.datepicker-dialog table.dates td:not(.disabled):hover { + padding: 2px; + border: 1px solid rgb(100 100 100); +} + +.datepicker-dialog table.dates td[aria-selected] { + padding: 1px; + border: 2px dotted rgb(100 100 100); +} + +.datepicker-dialog table.dates td[aria-selected]:focus { + padding: 1px; + border: 2px solid rgb(100 100 100); +} + +.datepicker-dialog table.dates td[tabindex="0"] { + background-color: hsl(216deg 80% 51%); + color: white; +} + +.datepicker-dialog .dialog-message { + padding-top: 0.25em; + padding-left: 1em; + height: 1.75em; + background: hsl(216deg 80% 51%); + color: white; +} diff --git a/lib/styles/modal-dialogue.css b/lib/styles/modal-dialogue.css new file mode 100644 index 0000000..bd2233b --- /dev/null +++ b/lib/styles/modal-dialogue.css @@ -0,0 +1,139 @@ +.hidden { + display: none; + } + + [role="dialog"] { + box-sizing: border-box; + padding: 15px; + border: 1px solid #000; + background-color: #fff; + min-height: 100vh; + } + + @media screen and (min-width: 640px) { + [role="dialog"] { + position: absolute; + top: 2rem; + left: 50vw; /* move to the middle of the screen (assumes relative parent is the body/viewport) */ + transform: translateX( + -50% + ); /* move backwards 50% of this element's width */ + + min-width: calc(640px - (15px * 2)); /* == breakpoint - left+right margin */ + min-height: auto; + box-shadow: + 0 19px 38px rgb(0 0 0 / 12%), + 0 15px 12px rgb(0 0 0 / 22%); + } + } + + .dialog_label { + text-align: center; + } + + .dialog_form { + margin: 15px; + } + + .dialog_form .label_text { + box-sizing: border-box; + padding-right: 0.5em; + display: inline-block; + font-size: 16px; + font-weight: bold; + width: 30%; + text-align: right; + } + + .dialog_form .label_info { + box-sizing: border-box; + padding-right: 0.5em; + font-size: 12px; + width: 30%; + text-align: right; + display: inline-block; + } + + .dialog_form_item { + margin: 10px 0; + font-size: 0; + } + + .dialog_form_item .wide_input { + box-sizing: border-box; + max-width: 70%; + width: 27em; + } + + .dialog_form_item .city_input { + box-sizing: border-box; + max-width: 70%; + width: 17em; + } + + .dialog_form_item .state_input { + box-sizing: border-box; + max-width: 70%; + width: 15em; + } + + .dialog_form_item .zip_input { + box-sizing: border-box; + max-width: 70%; + width: 9em; + } + + .dialog_form_actions { + text-align: right; + padding: 0 20px 20px; + } + + .dialog_close_button { + float: right; + position: absolute; + top: 10px; + left: 92%; + height: 25px; + } + + .dialog_close_button img { + border: 0; + } + + .dialog_desc { + padding: 10px 20px; + } + + /* native element uses the ::backdrop pseudo-element */ + + /* dialog::backdrop, */ + .dialog-backdrop { + display: none; + position: fixed; + overflow-y: auto; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + } + + @media screen and (min-width: 640px) { + .dialog-backdrop { + background: rgb(0 0 0 / 30%); + } + } + + .dialog-backdrop.active { + display: block; + } + + .no-scroll { + overflow-y: auto !important; + } + + /* this is added to the body when a dialog is open */ + .has-dialog { + overflow: hidden; + } + \ No newline at end of file diff --git a/lib/styles/style.css b/lib/theme/theme.css similarity index 100% rename from lib/styles/style.css rename to lib/theme/theme.css diff --git a/lib/widgets/accordian.js b/lib/widgets/accordian.js new file mode 100644 index 0000000..fd471a9 --- /dev/null +++ b/lib/widgets/accordian.js @@ -0,0 +1,60 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + * + * Simple accordion pattern example + */ + +'use strict'; + +class Accordion { + constructor(domNode) { + this.rootEl = domNode; + this.buttonEl = this.rootEl.querySelector('button[aria-expanded]'); + + const controlsId = this.buttonEl.getAttribute('aria-controls'); + this.contentEl = document.getElementById(controlsId); + + this.open = this.buttonEl.getAttribute('aria-expanded') === 'true'; + + // add event listeners + this.buttonEl.addEventListener('click', this.onButtonClick.bind(this)); + } + + onButtonClick() { + this.toggle(!this.open); + } + + toggle(open) { + // don't do anything if the open state doesn't change + if (open === this.open) { + return; + } + + // update the internal state + this.open = open; + + // handle DOM updates + this.buttonEl.setAttribute('aria-expanded', `${open}`); + if (open) { + this.contentEl.removeAttribute('hidden'); + } else { + this.contentEl.setAttribute('hidden', ''); + } + } + + // Add public open and close methods for convenience + open() { + this.toggle(true); + } + + close() { + this.toggle(false); + } +} + +// init accordions +const accordions = document.querySelectorAll('.accordion h3'); +accordions.forEach((accordionEl) => { + new Accordion(accordionEl); +}); diff --git a/lib/widgets/alert.js b/lib/widgets/alert.js new file mode 100644 index 0000000..d30af7a --- /dev/null +++ b/lib/widgets/alert.js @@ -0,0 +1,29 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + * + */ + +'use strict'; + +window.addEventListener('load', function () { + var button = document.getElementById('alert-trigger'); + + button.addEventListener('click', addAlert); +}); + +/* + * @function addAlert + * + * @desc Adds an alert to the page + * + * @param {object} event - Standard W3C event object + * + */ + +function addAlert() { + var example = document.getElementById('example'); + var template = document.getElementById('alert-template').innerHTML; + + example.innerHTML = template; +} diff --git a/lib/widgets/alert_dialogue.js b/lib/widgets/alert_dialogue.js new file mode 100644 index 0000000..a628906 --- /dev/null +++ b/lib/widgets/alert_dialogue.js @@ -0,0 +1,544 @@ +'use strict'; + +var aria = aria || {}; + +aria.Utils = aria.Utils || {}; + +/** + * @description Set focus on descendant nodes until the first focusable element is + * found. + * @param element + * DOM node for which to find the first focusable descendant. + * @returns {boolean} + * true if a focusable element is found and focus is set. + */ +aria.Utils.focusFirstDescendant = function (element) { + for (var i = 0; i < element.childNodes.length; i++) { + var child = element.childNodes[i]; + if ( + aria.Utils.attemptFocus(child) || + aria.Utils.focusFirstDescendant(child) + ) { + return true; + } + } + return false; +}; // end focusFirstDescendant + +/** + * @description Set Attempt to set focus on the current node. + * @param element + * The node to attempt to focus on. + * @returns {boolean} + * true if element is focused. + */ +aria.Utils.attemptFocus = function (element) { + if (!aria.Utils.isFocusable(element)) { + return false; + } + + aria.Utils.IgnoreUtilFocusChanges = true; + try { + element.focus(); + } catch (e) { + // continue regardless of error + } + aria.Utils.IgnoreUtilFocusChanges = false; + return document.activeElement === element; +}; // end attemptFocus + +aria.handleEscape = function (event) { + var key = event.which || event.keyCode; + + if (key === aria.KeyCode.ESC && aria.openedDialog) { + aria.openedDialog.close(); + event.stopPropagation(); + } +}; + +document.addEventListener('keyup', aria.handleEscape); + +/** + * @class + * @description Dialog object providing modal focus management. + * + * Assumptions: The element serving as the dialog container is present in the + * DOM and hidden. The dialog container has role='dialog'. + * @param dialogId + * The ID of the element serving as the dialog container. + * @param focusAfterClosed + * Either the DOM node or the ID of the DOM node to focus when the + * dialog closes. + * @param focusFirst + * Optional parameter containing either the DOM node or the ID of the + * DOM node to focus when the dialog opens. If not specified, the + * first focusable element in the dialog will receive focus. + */ +aria.Dialog = function (dialogId, focusAfterClosed, focusFirst) { + this.dialogNode = document.getElementById(dialogId); + if (this.dialogNode === null) { + throw new Error('No element found with id="' + dialogId + '".'); + } + + var validRoles = ['dialog', 'alertdialog']; + var isDialog = (this.dialogNode.getAttribute('role') || '') + .trim() + .split(/\s+/g) + .some(function (token) { + return validRoles.some(function (role) { + return token === role; + }); + }); + if (!isDialog) { + throw new Error( + 'Dialog() requires a DOM element with ARIA role of dialog or alertdialog.' + ); + } + + // Wrap in an individual backdrop element if one doesn't exist + // Native elements use the ::backdrop pseudo-element, which + // works similarly. + var backdropClass = 'dialog-backdrop'; + if (this.dialogNode.parentNode.classList.contains(backdropClass)) { + this.backdropNode = this.dialogNode.parentNode; + } else { + this.backdropNode = document.createElement('div'); + this.backdropNode.className = backdropClass; + this.dialogNode.parentNode.insertBefore(this.backdropNode, this.dialogNode); + this.backdropNode.appendChild(this.dialogNode); + } + this.backdropNode.classList.add('active'); + + // Disable scroll on the body element + document.body.classList.add(aria.Utils.dialogOpenClass); + + if (typeof focusAfterClosed === 'string') { + this.focusAfterClosed = document.getElementById(focusAfterClosed); + } else if (typeof focusAfterClosed === 'object') { + this.focusAfterClosed = focusAfterClosed; + } else { + throw new Error( + 'the focusAfterClosed parameter is required for the aria.Dialog constructor.' + ); + } + + if (typeof focusFirst === 'string') { + this.focusFirst = document.getElementById(focusFirst); + } else if (typeof focusFirst === 'object') { + this.focusFirst = focusFirst; + } else { + this.focusFirst = null; + } + + // Bracket the dialog node with two invisible, focusable nodes. + // While this dialog is open, we use these to make sure that focus never + // leaves the document even if dialogNode is the first or last node. + var preDiv = document.createElement('div'); + this.preNode = this.dialogNode.parentNode.insertBefore( + preDiv, + this.dialogNode + ); + this.preNode.tabIndex = 0; + var postDiv = document.createElement('div'); + this.postNode = this.dialogNode.parentNode.insertBefore( + postDiv, + this.dialogNode.nextSibling + ); + this.postNode.tabIndex = 0; + + this.addListeners(); + aria.openedDialog = this; + this.dialogNode.className = 'default_dialog'; // make visible + + if (this.focusFirst) { + this.focusFirst.focus(); + } else { + aria.Utils.focusFirstDescendant(this.dialogNode); + } + + this.lastFocus = document.activeElement; +}; // end Dialog constructor + +/** + * @description + * Hides the current top dialog, + * removes listeners of the top dialog, + * restore listeners of a parent dialog if one was open under the one that just closed, + * and sets focus on the element specified for focusAfterClosed. + */ +aria.Dialog.prototype.close = function () { + aria.openedDialog = null; + this.removeListeners(); + aria.Utils.remove(this.preNode); + aria.Utils.remove(this.postNode); + this.dialogNode.className = 'hidden'; + this.backdropNode.classList.remove('active'); + this.focusAfterClosed.focus(); + + document.body.classList.remove(aria.Utils.dialogOpenClass); +}; // end close + +aria.Dialog.prototype.addListeners = function () { + document.addEventListener('focus', this.trapFocus, true); +}; // end addListeners + +aria.Dialog.prototype.removeListeners = function () { + document.removeEventListener('focus', this.trapFocus, true); +}; // end removeListeners + +aria.Dialog.prototype.trapFocus = function (event) { + if (aria.Utils.IgnoreUtilFocusChanges) { + return; + } + var opened = aria.openedDialog; + if (opened.dialogNode.contains(event.target)) { + opened.lastFocus = event.target; + } else { + aria.Utils.focusFirstDescendant(opened.dialogNode); + if (opened.lastFocus == document.activeElement) { + aria.Utils.focusLastDescendant(opened.dialogNode); + } + opened.lastFocus = document.activeElement; + } +}; // end trapFocus + +aria.Utils.disableCtrl = function (ctrl) { + ctrl.setAttribute('aria-disabled', 'true'); +}; + +aria.Utils.enableCtrl = function (ctrl) { + ctrl.removeAttribute('aria-disabled'); +}; + +aria.Utils.setLoading = function (saveBtn, saveStatusView) { + saveBtn.classList.add('loading'); + this.disableCtrl(saveBtn); + + // use a timeout for the loading message + // if the saved state happens very quickly, + // we don't need to explicitly announce the intermediate loading state + const loadingTimeout = window.setTimeout(() => { + saveStatusView.textContent = 'Loading'; + }, 200); + + // set timeout for saved state, to mimic loading + const fakeLoadingTimeout = Math.random() * 2000; + window.setTimeout(() => { + saveBtn.classList.remove('loading'); + saveBtn.classList.add('saved'); + + window.clearTimeout(loadingTimeout); + saveStatusView.textContent = 'Saved successfully'; + }, fakeLoadingTimeout); +}; + +aria.Notes = function Notes( + notesId, + saveId, + saveStatusId, + discardId, + localStorageKey +) { + this.notesInput = document.getElementById(notesId); + this.saveBtn = document.getElementById(saveId); + this.saveStatusView = document.getElementById(saveStatusId); + this.discardBtn = document.getElementById(discardId); + this.localStorageKey = localStorageKey || 'alertdialog-notes'; + this.initialized = false; + + Object.defineProperty(this, 'controls', { + get: function () { + return document.querySelectorAll( + '[data-textbox=' + this.notesInput.id + ']' + ); + }, + }); + Object.defineProperty(this, 'hasContent', { + get: function () { + return this.notesInput.value.length > 0; + }, + }); + Object.defineProperty(this, 'savedValue', { + get: function () { + return JSON.parse(localStorage.getItem(this.localStorageKey)); + }, + set: function (val) { + this.save(val); + }, + }); + Object.defineProperty(this, 'isCurrent', { + get: function () { + return this.notesInput.value === this.savedValue; + }, + }); + Object.defineProperty(this, 'oninput', { + get: function () { + return this.notesInput.oninput; + }, + set: function (fn) { + if (typeof fn !== 'function') { + throw new TypeError('oninput must be a function'); + } + this.notesInput.addEventListener('input', fn); + }, + }); + + if (this.saveBtn && this.discardBtn) { + this.init(); + } +}; + +aria.Notes.prototype.save = function (val) { + const isDisabled = this.saveBtn.getAttribute('aria-disabled') === 'true'; + if (isDisabled) { + return; + } + localStorage.setItem( + this.localStorageKey, + JSON.stringify(val || this.notesInput.value) + ); + aria.Utils.disableCtrl(this.saveBtn); + aria.Utils.setLoading(this.saveBtn, this.saveStatusView); +}; + +aria.Notes.prototype.loadSaved = function () { + if (this.savedValue) { + this.notesInput.value = this.savedValue; + } +}; + +aria.Notes.prototype.restoreSaveBtn = function () { + this.saveBtn.classList.remove('loading'); + this.saveBtn.classList.remove('saved'); + this.saveBtn.removeAttribute('aria-disabled'); + + this.saveStatusView.textContent = ''; +}; + +aria.Notes.prototype.discard = function () { + localStorage.clear(); + this.notesInput.value = ''; + this.toggleControls(); + this.restoreSaveBtn(); +}; + +aria.Notes.prototype.disableControls = function () { + this.controls.forEach(aria.Utils.disableCtrl); +}; + +aria.Notes.prototype.enableControls = function () { + this.controls.forEach(aria.Utils.enableCtrl); +}; + +aria.Notes.prototype.toggleControls = function () { + if (this.hasContent) { + this.enableControls(); + } else { + this.disableControls(); + } +}; + +aria.Notes.prototype.toggleCurrent = function () { + if (!this.isCurrent) { + this.notesInput.classList.remove('can-save'); + aria.Utils.enableCtrl(this.saveBtn); + this.restoreSaveBtn(); + } else { + this.notesInput.classList.add('can-save'); + aria.Utils.disableCtrl(this.saveBtn); + } +}; + +aria.Notes.prototype.keydownHandler = function (e) { + var mod = navigator.userAgent.includes('Mac') ? e.metaKey : e.ctrlKey; + if ((e.key === 's') & mod) { + e.preventDefault(); + this.save(); + } +}; + +aria.Notes.prototype.init = function () { + if (!this.initialized) { + this.loadSaved(); + this.toggleCurrent(); + this.saveBtn.addEventListener('click', this.save.bind(this, undefined)); + this.discardBtn.addEventListener('click', this.discard.bind(this)); + this.notesInput.addEventListener('input', this.toggleControls.bind(this)); + this.notesInput.addEventListener('input', this.toggleCurrent.bind(this)); + this.notesInput.addEventListener('keydown', this.keydownHandler.bind(this)); + this.initialized = true; + } +}; + +/** initialization */ +document.addEventListener('DOMContentLoaded', function initAlertDialog() { + var notes = new aria.Notes( + 'notes', + 'notes_save', + 'notes_save_status', + 'notes_confirm' + ); + + window.closeDialog = function () { + aria.openedDialog.close(); + }; // end closeDialog + + window.discardInput = function () { + notes.discard.call(notes); + window.closeDialog(); + }; + + window.openAlertDialog = function (dialogId, triggerBtn, focusFirst) { + // do not proceed if the trigger button is disabled + if (triggerBtn.getAttribute('aria-disabled') === 'true') { + return; + } + + var target = document.getElementById( + triggerBtn.getAttribute('data-textbox') + ); + var dialog = document.getElementById(dialogId); + var desc = document.getElementById(dialog.getAttribute('aria-describedby')); + var wordCount = document.getElementById('word_count'); + if (!wordCount) { + wordCount = document.createElement('p'); + wordCount.id = 'word_count'; + desc.appendChild(wordCount); + } + var count = target.value.split(/\s/).length; + var frag = count > 1 ? 'words' : 'word'; + wordCount.textContent = count + ' ' + frag + ' will be deleted.'; + new aria.Dialog(dialogId, target, focusFirst); + }; +}); +'use strict'; +/** + * @namespace aria + */ + +var aria = aria || {}; + +/** + * @description + * Key code constants + */ +aria.KeyCode = { + BACKSPACE: 8, + TAB: 9, + RETURN: 13, + SHIFT: 16, + ESC: 27, + SPACE: 32, + PAGE_UP: 33, + PAGE_DOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + DELETE: 46, +}; + +aria.Utils = aria.Utils || {}; + +// Polyfill src https://developer.mozilla.org/en-US/docs/Web/API/Element/matches +aria.Utils.matches = function (element, selector) { + if (!Element.prototype.matches) { + Element.prototype.matches = + Element.prototype.matchesSelector || + Element.prototype.mozMatchesSelector || + Element.prototype.msMatchesSelector || + Element.prototype.oMatchesSelector || + Element.prototype.webkitMatchesSelector || + function (s) { + var matches = element.parentNode.querySelectorAll(s); + var i = matches.length; + while (--i >= 0 && matches.item(i) !== this) { + // empty + } + return i > -1; + }; + } + + return element.matches(selector); +}; + +aria.Utils.remove = function (item) { + if (item.remove && typeof item.remove === 'function') { + return item.remove(); + } + if ( + item.parentNode && + item.parentNode.removeChild && + typeof item.parentNode.removeChild === 'function' + ) { + return item.parentNode.removeChild(item); + } + return false; +}; + +aria.Utils.isFocusable = function (element) { + if (element.tabIndex < 0) { + return false; + } + + if (element.disabled) { + return false; + } + + switch (element.nodeName) { + case 'A': + return !!element.href && element.rel != 'ignore'; + case 'INPUT': + return element.type != 'hidden'; + case 'BUTTON': + case 'SELECT': + case 'TEXTAREA': + return true; + default: + return false; + } +}; + +aria.Utils.getAncestorBySelector = function (element, selector) { + if (!aria.Utils.matches(element, selector + ' ' + element.tagName)) { + // Element is not inside an element that matches selector + return null; + } + + // Move up the DOM tree until a parent matching the selector is found + var currentNode = element; + var ancestor = null; + while (ancestor === null) { + if (aria.Utils.matches(currentNode.parentNode, selector)) { + ancestor = currentNode.parentNode; + } else { + currentNode = currentNode.parentNode; + } + } + + return ancestor; +}; + +aria.Utils.hasClass = function (element, className) { + return new RegExp('(\\s|^)' + className + '(\\s|$)').test(element.className); +}; + +aria.Utils.addClass = function (element, className) { + if (!aria.Utils.hasClass(element, className)) { + element.className += ' ' + className; + } +}; + +aria.Utils.removeClass = function (element, className) { + var classRegex = new RegExp('(\\s|^)' + className + '(\\s|$)'); + element.className = element.className.replace(classRegex, ' ').trim(); +}; + +aria.Utils.bindMethods = function (object /* , ...methodNames */) { + var methodNames = Array.prototype.slice.call(arguments, 1); + methodNames.forEach(function (method) { + object[method] = object[method].bind(object); + }); +}; diff --git a/lib/widgets/button.js b/lib/widgets/button.js new file mode 100644 index 0000000..b27c129 --- /dev/null +++ b/lib/widgets/button.js @@ -0,0 +1,118 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + * + * JS code for the button design pattern + */ + +'use strict'; + +var ICON_MUTE_URL = '#icon-mute'; +var ICON_SOUND_URL = '#icon-sound'; + +function init() { + var actionButton = document.getElementById('action'); + actionButton.addEventListener('click', activateActionButton); + actionButton.addEventListener('keydown', actionButtonKeydownHandler); + actionButton.addEventListener('keyup', actionButtonKeyupHandler); + + var toggleButton = document.getElementById('toggle'); + toggleButton.addEventListener('click', toggleButtonClickHandler); + toggleButton.addEventListener('keydown', toggleButtonKeydownHandler); + toggleButton.addEventListener('keyup', toggleButtonKeyupHandler); +} + +/** + * Activates the action button with the enter key. + * + * @param {KeyboardEvent} event + */ +function actionButtonKeydownHandler(event) { + // The action button is activated by space on the keyup event, but the + // default action for space is already triggered on keydown. It needs to be + // prevented to stop scrolling the page before activating the button. + if (event.keyCode === 32) { + event.preventDefault(); + } + // If enter is pressed, activate the button + else if (event.keyCode === 13) { + event.preventDefault(); + activateActionButton(); + } +} + +/** + * Activates the action button with the space key. + * + * @param {KeyboardEvent} event + */ +function actionButtonKeyupHandler(event) { + if (event.keyCode === 32) { + event.preventDefault(); + activateActionButton(); + } +} + +function activateActionButton() { + window.print(); +} + +/** + * Toggles the toggle button’s state if it’s actually a button element or has + * the `role` attribute set to `button`. + * + * @param {MouseEvent} event + */ +function toggleButtonClickHandler(event) { + if ( + event.currentTarget.tagName === 'button' || + event.currentTarget.getAttribute('role') === 'button' + ) { + toggleButtonState(event.currentTarget); + } +} + +/** + * Toggles the toggle button’s state with the enter key. + * + * @param {KeyboardEvent} event + */ +function toggleButtonKeydownHandler(event) { + if (event.keyCode === 32) { + event.preventDefault(); + } else if (event.keyCode === 13) { + event.preventDefault(); + toggleButtonState(event.currentTarget); + } +} + +/** + * Toggles the toggle button’s state with space key. + * + * @param {KeyboardEvent} event + */ +function toggleButtonKeyupHandler(event) { + if (event.keyCode === 32) { + event.preventDefault(); + toggleButtonState(event.currentTarget); + } +} + +/** + * Toggles the toggle button’s state between *pressed* and *not pressed*. + * + * @param {HTMLElement} button + */ +function toggleButtonState(button) { + var isAriaPressed = button.getAttribute('aria-pressed') === 'true'; + + button.setAttribute('aria-pressed', isAriaPressed ? 'false' : 'true'); + + var icon = button.querySelector('use'); + icon.setAttribute( + 'xlink:href', + isAriaPressed ? ICON_SOUND_URL : ICON_MUTE_URL + ); +} + +window.onload = init; diff --git a/lib/widgets/carousel.js b/lib/widgets/carousel.js new file mode 100644 index 0000000..ee92b72 --- /dev/null +++ b/lib/widgets/carousel.js @@ -0,0 +1,327 @@ +/* + * File: carousel-prev-next.js + * + * Desc: Carousel widget with Previous and Next Buttons that implements ARIA Authoring Practices + * + */ + +'use strict'; + +var CarouselPreviousNext = function (node, options) { + // merge passed options with defaults + options = Object.assign( + { moreaccessible: false, paused: false, norotate: false }, + options || {} + ); + + // a prefers-reduced-motion user setting must always override autoplay + var hasReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + if (hasReducedMotion.matches) { + options.paused = true; + } + + /* DOM properties */ + this.domNode = node; + + this.carouselItemNodes = node.querySelectorAll('.carousel-item'); + + this.containerNode = node.querySelector('.carousel-items'); + this.liveRegionNode = node.querySelector('.carousel-items'); + this.pausePlayButtonNode = null; + this.previousButtonNode = null; + this.nextButtonNode = null; + + this.playLabel = 'Start automatic slide show'; + this.pauseLabel = 'Stop automatic slide show'; + + /* State properties */ + this.hasUserActivatedPlay = false; // set when the user activates the play/pause button + this.isAutoRotationDisabled = options.norotate; // This property for disabling auto rotation + this.isPlayingEnabled = !options.paused; // This property is also set in updatePlaying method + this.timeInterval = 5000; // length of slide rotation in ms + this.currentIndex = 0; // index of current slide + this.slideTimeout = null; // save reference to setTimeout + + // Pause Button + + var elem = document.querySelector('.carousel .controls button.rotation'); + if (elem) { + this.pausePlayButtonNode = elem; + this.pausePlayButtonNode.addEventListener( + 'click', + this.handlePausePlayButtonClick.bind(this) + ); + } + + // Previous Button + + elem = document.querySelector('.carousel .controls button.previous'); + if (elem) { + this.previousButtonNode = elem; + this.previousButtonNode.addEventListener( + 'click', + this.handlePreviousButtonClick.bind(this) + ); + this.previousButtonNode.addEventListener( + 'focus', + this.handleFocusIn.bind(this) + ); + this.previousButtonNode.addEventListener( + 'blur', + this.handleFocusOut.bind(this) + ); + } + + // Next Button + + elem = document.querySelector('.carousel .controls button.next'); + if (elem) { + this.nextButtonNode = elem; + this.nextButtonNode.addEventListener( + 'click', + this.handleNextButtonClick.bind(this) + ); + this.nextButtonNode.addEventListener( + 'focus', + this.handleFocusIn.bind(this) + ); + this.nextButtonNode.addEventListener( + 'blur', + this.handleFocusOut.bind(this) + ); + } + + // Carousel item events + + for (var i = 0; i < this.carouselItemNodes.length; i++) { + var carouselItemNode = this.carouselItemNodes[i]; + + // support stopping rotation when any element receives focus in the tabpanel + carouselItemNode.addEventListener('focusin', this.handleFocusIn.bind(this)); + carouselItemNode.addEventListener( + 'focusout', + this.handleFocusOut.bind(this) + ); + + var imageLinkNode = carouselItemNode.querySelector('.carousel-image a'); + + if (imageLinkNode) { + imageLinkNode.addEventListener( + 'focus', + this.handleImageLinkFocus.bind(this) + ); + imageLinkNode.addEventListener( + 'blur', + this.handleImageLinkBlur.bind(this) + ); + } + } + + // Handle hover events + this.domNode.addEventListener('mouseover', this.handleMouseOver.bind(this)); + this.domNode.addEventListener('mouseout', this.handleMouseOut.bind(this)); + + // initialize behavior based on options + + this.enableOrDisableAutoRotation(options.norotate); + this.updatePlaying(!options.paused && !options.norotate); + this.setAccessibleStyling(options.moreaccessible); + this.rotateSlides(); +}; + +/* Public function to disable/enable rotation and if false, hide pause/play button*/ +CarouselPreviousNext.prototype.enableOrDisableAutoRotation = function ( + disable +) { + this.isAutoRotationDisabled = disable; + this.pausePlayButtonNode.hidden = disable; +}; + +/* Public function to update controls/caption styling */ +CarouselPreviousNext.prototype.setAccessibleStyling = function (accessible) { + if (accessible) { + this.domNode.classList.add('carousel-moreaccessible'); + } else { + this.domNode.classList.remove('carousel-moreaccessible'); + } +}; + +CarouselPreviousNext.prototype.showCarouselItem = function (index) { + this.currentIndex = index; + + for (var i = 0; i < this.carouselItemNodes.length; i++) { + var carouselItemNode = this.carouselItemNodes[i]; + if (index === i) { + carouselItemNode.classList.add('active'); + } else { + carouselItemNode.classList.remove('active'); + } + } +}; + +CarouselPreviousNext.prototype.previousCarouselItem = function () { + var nextIndex = this.currentIndex - 1; + if (nextIndex < 0) { + nextIndex = this.carouselItemNodes.length - 1; + } + this.showCarouselItem(nextIndex); +}; + +CarouselPreviousNext.prototype.nextCarouselItem = function () { + var nextIndex = this.currentIndex + 1; + if (nextIndex >= this.carouselItemNodes.length) { + nextIndex = 0; + } + this.showCarouselItem(nextIndex); +}; + +CarouselPreviousNext.prototype.rotateSlides = function () { + if (!this.isAutoRotationDisabled) { + if ( + (!this.hasFocus && !this.hasHover && this.isPlayingEnabled) || + this.hasUserActivatedPlay + ) { + this.nextCarouselItem(); + } + } + + this.slideTimeout = setTimeout( + this.rotateSlides.bind(this), + this.timeInterval + ); +}; + +CarouselPreviousNext.prototype.updatePlaying = function (play) { + this.isPlayingEnabled = play; + + if (play) { + this.pausePlayButtonNode.setAttribute('aria-label', this.pauseLabel); + this.pausePlayButtonNode.classList.remove('play'); + this.pausePlayButtonNode.classList.add('pause'); + this.liveRegionNode.setAttribute('aria-live', 'off'); + } else { + this.pausePlayButtonNode.setAttribute('aria-label', this.playLabel); + this.pausePlayButtonNode.classList.remove('pause'); + this.pausePlayButtonNode.classList.add('play'); + this.liveRegionNode.setAttribute('aria-live', 'polite'); + } +}; + +/* Event Handlers */ + +CarouselPreviousNext.prototype.handleImageLinkFocus = function () { + this.liveRegionNode.classList.add('focus'); +}; + +CarouselPreviousNext.prototype.handleImageLinkBlur = function () { + this.liveRegionNode.classList.remove('focus'); +}; + +CarouselPreviousNext.prototype.handleMouseOver = function (event) { + if (!this.pausePlayButtonNode.contains(event.target)) { + this.hasHover = true; + } +}; + +CarouselPreviousNext.prototype.handleMouseOut = function () { + this.hasHover = false; +}; + +/* EVENT HANDLERS */ + +CarouselPreviousNext.prototype.handlePausePlayButtonClick = function () { + this.hasUserActivatedPlay = !this.isPlayingEnabled; + this.updatePlaying(!this.isPlayingEnabled); +}; + +CarouselPreviousNext.prototype.handlePreviousButtonClick = function () { + this.previousCarouselItem(); +}; + +CarouselPreviousNext.prototype.handleNextButtonClick = function () { + this.nextCarouselItem(); +}; + +/* Event Handlers for carousel items*/ + +CarouselPreviousNext.prototype.handleFocusIn = function () { + this.liveRegionNode.setAttribute('aria-live', 'polite'); + this.hasFocus = true; +}; + +CarouselPreviousNext.prototype.handleFocusOut = function () { + if (this.isPlayingEnabled) { + this.liveRegionNode.setAttribute('aria-live', 'off'); + } + this.hasFocus = false; +}; + +/* Initialize Carousel and options */ + +window.addEventListener( + 'load', + function () { + var carouselEls = document.querySelectorAll('.carousel'); + var carousels = []; + + // set example behavior based on + // default setting of the checkboxes and the parameters in the URL + // update checkboxes based on any corresponding URL parameters + var checkboxes = document.querySelectorAll( + '.carousel-options input[type=checkbox]' + ); + var urlParams = new URLSearchParams(location.search); + var carouselOptions = {}; + + // initialize example features based on + // default setting of the checkboxes and the parameters in the URL + // update checkboxes based on any corresponding URL parameters + checkboxes.forEach(function (checkbox) { + var checked = checkbox.checked; + + if (urlParams.has(checkbox.value)) { + var urlParam = urlParams.get(checkbox.value); + if (typeof urlParam === 'string') { + checked = urlParam === 'true'; + checkbox.checked = checked; + } + } + + carouselOptions[checkbox.value] = checkbox.checked; + }); + + carouselEls.forEach(function (node) { + carousels.push(new CarouselPreviousNext(node, carouselOptions)); + }); + + // add change event to checkboxes + checkboxes.forEach(function (checkbox) { + var updateEvent; + switch (checkbox.value) { + case 'moreaccessible': + updateEvent = 'setAccessibleStyling'; + break; + case 'norotate': + updateEvent = 'enableOrDisableAutoRotation'; + break; + } + + // update the carousel behavior and URL when a checkbox state changes + checkbox.addEventListener('change', function (event) { + urlParams.set(event.target.value, event.target.checked + ''); + window.history.replaceState( + null, + '', + window.location.pathname + '?' + urlParams + ); + + if (updateEvent) { + carousels.forEach(function (carousel) { + carousel[updateEvent](event.target.checked); + }); + } + }); + }); + }, + false +); diff --git a/lib/widgets/checkbox.js b/lib/widgets/checkbox.js new file mode 100644 index 0000000..bf1cf80 --- /dev/null +++ b/lib/widgets/checkbox.js @@ -0,0 +1,174 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + * + * File: CheckboxMixed.js + * + * Desc: CheckboxMixed widget that implements ARIA Authoring Practices + * for a menu of links + */ + +'use strict'; + +class CheckboxMixed { + constructor(domNode) { + this.mixedNode = domNode.querySelector('[role="checkbox"]'); + this.checkboxNodes = domNode.querySelectorAll('input[type="checkbox"]'); + + this.mixedNode.addEventListener('keydown', this.onMixedKeydown.bind(this)); + this.mixedNode.addEventListener('keyup', this.onMixedKeyup.bind(this)); + this.mixedNode.addEventListener('click', this.onMixedClick.bind(this)); + this.mixedNode.addEventListener('focus', this.onMixedFocus.bind(this)); + this.mixedNode.addEventListener('blur', this.onMixedBlur.bind(this)); + + for (var i = 0; i < this.checkboxNodes.length; i++) { + var checkboxNode = this.checkboxNodes[i]; + + checkboxNode.addEventListener('click', this.onCheckboxClick.bind(this)); + checkboxNode.addEventListener('focus', this.onCheckboxFocus.bind(this)); + checkboxNode.addEventListener('blur', this.onCheckboxBlur.bind(this)); + checkboxNode.setAttribute('data-last-state', checkboxNode.checked); + } + + this.updateMixed(); + } + + updateMixed() { + var count = 0; + + for (var i = 0; i < this.checkboxNodes.length; i++) { + if (this.checkboxNodes[i].checked) { + count++; + } + } + + if (count === 0) { + this.mixedNode.setAttribute('aria-checked', 'false'); + } else { + if (count === this.checkboxNodes.length) { + this.mixedNode.setAttribute('aria-checked', 'true'); + } else { + this.mixedNode.setAttribute('aria-checked', 'mixed'); + this.updateCheckboxStates(); + } + } + } + + updateCheckboxStates() { + for (var i = 0; i < this.checkboxNodes.length; i++) { + var checkboxNode = this.checkboxNodes[i]; + checkboxNode.setAttribute('data-last-state', checkboxNode.checked); + } + } + + anyLastChecked() { + var count = 0; + + for (var i = 0; i < this.checkboxNodes.length; i++) { + if (this.checkboxNodes[i].getAttribute('data-last-state') == 'true') { + count++; + } + } + + return count > 0; + } + + setCheckboxes(value) { + for (var i = 0; i < this.checkboxNodes.length; i++) { + var checkboxNode = this.checkboxNodes[i]; + + switch (value) { + case 'last': + checkboxNode.checked = + checkboxNode.getAttribute('data-last-state') === 'true'; + break; + + case 'true': + checkboxNode.checked = true; + break; + + default: + checkboxNode.checked = false; + break; + } + } + this.updateMixed(); + } + + toggleMixed() { + var state = this.mixedNode.getAttribute('aria-checked'); + + if (state === 'false') { + if (this.anyLastChecked()) { + this.setCheckboxes('last'); + } else { + this.setCheckboxes('true'); + } + } else { + if (state === 'mixed') { + this.setCheckboxes('true'); + } else { + this.setCheckboxes('false'); + } + } + + this.updateMixed(); + } + + /* EVENT HANDLERS */ + + // Prevent page scrolling on space down + onMixedKeydown(event) { + if (event.key === ' ') { + event.preventDefault(); + } + } + + onMixedKeyup(event) { + switch (event.key) { + case ' ': + this.toggleMixed(); + event.stopPropagation(); + break; + + default: + break; + } + } + + onMixedClick() { + this.toggleMixed(); + } + + onMixedFocus() { + this.mixedNode.classList.add('focus'); + } + + onMixedBlur() { + this.mixedNode.classList.remove('focus'); + } + + onCheckboxClick(event) { + event.currentTarget.setAttribute( + 'data-last-state', + event.currentTarget.checked + ); + this.updateMixed(); + } + + onCheckboxFocus(event) { + event.currentTarget.parentNode.classList.add('focus'); + } + + onCheckboxBlur(event) { + event.currentTarget.parentNode.classList.remove('focus'); + } +} + +// Initialize mixed checkboxes on the page +window.addEventListener('load', function () { + let mixed = document.querySelectorAll('.checkbox-mixed'); + for (let i = 0; i < mixed.length; i++) { + new CheckboxMixed(mixed[i]); + } +}); diff --git a/lib/widgets/combobox.js b/lib/widgets/combobox.js new file mode 100644 index 0000000..98cb12a --- /dev/null +++ b/lib/widgets/combobox.js @@ -0,0 +1,600 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + */ + +'use strict'; + +class ComboboxAutocomplete { + constructor(comboboxNode, buttonNode, listboxNode) { + this.comboboxNode = comboboxNode; + this.buttonNode = buttonNode; + this.listboxNode = listboxNode; + + this.comboboxHasVisualFocus = false; + this.listboxHasVisualFocus = false; + + this.hasHover = false; + + this.isNone = false; + this.isList = false; + this.isBoth = false; + + this.allOptions = []; + + this.option = null; + this.firstOption = null; + this.lastOption = null; + + this.filteredOptions = []; + this.filter = ''; + + var autocomplete = this.comboboxNode.getAttribute('aria-autocomplete'); + + if (typeof autocomplete === 'string') { + autocomplete = autocomplete.toLowerCase(); + this.isNone = autocomplete === 'none'; + this.isList = autocomplete === 'list'; + this.isBoth = autocomplete === 'both'; + } else { + // default value of autocomplete + this.isNone = true; + } + + this.comboboxNode.addEventListener( + 'keydown', + this.onComboboxKeyDown.bind(this) + ); + this.comboboxNode.addEventListener( + 'keyup', + this.onComboboxKeyUp.bind(this) + ); + this.comboboxNode.addEventListener( + 'click', + this.onComboboxClick.bind(this) + ); + this.comboboxNode.addEventListener( + 'focus', + this.onComboboxFocus.bind(this) + ); + this.comboboxNode.addEventListener('blur', this.onComboboxBlur.bind(this)); + + document.body.addEventListener( + 'pointerup', + this.onBackgroundPointerUp.bind(this), + true + ); + + // initialize pop up menu + + this.listboxNode.addEventListener( + 'pointerover', + this.onListboxPointerover.bind(this) + ); + this.listboxNode.addEventListener( + 'pointerout', + this.onListboxPointerout.bind(this) + ); + + // Traverse the element children of domNode: configure each with + // option role behavior and store reference in.options array. + var nodes = this.listboxNode.getElementsByTagName('LI'); + + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + this.allOptions.push(node); + + node.addEventListener('click', this.onOptionClick.bind(this)); + node.addEventListener('pointerover', this.onOptionPointerover.bind(this)); + node.addEventListener('pointerout', this.onOptionPointerout.bind(this)); + } + + this.filterOptions(); + + // Open Button + + var button = this.comboboxNode.nextElementSibling; + + if (button && button.tagName === 'BUTTON') { + button.addEventListener('click', this.onButtonClick.bind(this)); + } + } + + getLowercaseContent(node) { + return node.textContent.toLowerCase(); + } + + isOptionInView(option) { + var bounding = option.getBoundingClientRect(); + return ( + bounding.top >= 0 && + bounding.left >= 0 && + bounding.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + bounding.right <= + (window.innerWidth || document.documentElement.clientWidth) + ); + } + + setActiveDescendant(option) { + if (option && this.listboxHasVisualFocus) { + this.comboboxNode.setAttribute('aria-activedescendant', option.id); + if (!this.isOptionInView(option)) { + option.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } else { + this.comboboxNode.setAttribute('aria-activedescendant', ''); + } + } + + setValue(value) { + this.filter = value; + this.comboboxNode.value = this.filter; + this.comboboxNode.setSelectionRange(this.filter.length, this.filter.length); + this.filterOptions(); + } + + setOption(option, flag) { + if (typeof flag !== 'boolean') { + flag = false; + } + + if (option) { + this.option = option; + this.setCurrentOptionStyle(this.option); + this.setActiveDescendant(this.option); + + if (this.isBoth) { + this.comboboxNode.value = this.option.textContent; + if (flag) { + this.comboboxNode.setSelectionRange( + this.option.textContent.length, + this.option.textContent.length + ); + } else { + this.comboboxNode.setSelectionRange( + this.filter.length, + this.option.textContent.length + ); + } + } + } + } + + setVisualFocusCombobox() { + this.listboxNode.classList.remove('focus'); + this.comboboxNode.parentNode.classList.add('focus'); // set the focus class to the parent for easier styling + this.comboboxHasVisualFocus = true; + this.listboxHasVisualFocus = false; + this.setActiveDescendant(false); + } + + setVisualFocusListbox() { + this.comboboxNode.parentNode.classList.remove('focus'); + this.comboboxHasVisualFocus = false; + this.listboxHasVisualFocus = true; + this.listboxNode.classList.add('focus'); + this.setActiveDescendant(this.option); + } + + removeVisualFocusAll() { + this.comboboxNode.parentNode.classList.remove('focus'); + this.comboboxHasVisualFocus = false; + this.listboxHasVisualFocus = false; + this.listboxNode.classList.remove('focus'); + this.option = null; + this.setActiveDescendant(false); + } + + // ComboboxAutocomplete Events + + filterOptions() { + // do not filter any options if autocomplete is none + if (this.isNone) { + this.filter = ''; + } + + var option = null; + var currentOption = this.option; + var filter = this.filter.toLowerCase(); + + this.filteredOptions = []; + this.listboxNode.innerHTML = ''; + + for (var i = 0; i < this.allOptions.length; i++) { + option = this.allOptions[i]; + if ( + filter.length === 0 || + this.getLowercaseContent(option).indexOf(filter) === 0 + ) { + this.filteredOptions.push(option); + this.listboxNode.appendChild(option); + } + } + + // Use populated options array to initialize firstOption and lastOption. + var numItems = this.filteredOptions.length; + if (numItems > 0) { + this.firstOption = this.filteredOptions[0]; + this.lastOption = this.filteredOptions[numItems - 1]; + + if (currentOption && this.filteredOptions.indexOf(currentOption) >= 0) { + option = currentOption; + } else { + option = this.firstOption; + } + } else { + this.firstOption = null; + option = null; + this.lastOption = null; + } + + return option; + } + + setCurrentOptionStyle(option) { + for (var i = 0; i < this.filteredOptions.length; i++) { + var opt = this.filteredOptions[i]; + if (opt === option) { + opt.setAttribute('aria-selected', 'true'); + if ( + this.listboxNode.scrollTop + this.listboxNode.offsetHeight < + opt.offsetTop + opt.offsetHeight + ) { + this.listboxNode.scrollTop = + opt.offsetTop + opt.offsetHeight - this.listboxNode.offsetHeight; + } else if (this.listboxNode.scrollTop > opt.offsetTop + 2) { + this.listboxNode.scrollTop = opt.offsetTop; + } + } else { + opt.removeAttribute('aria-selected'); + } + } + } + + getPreviousOption(currentOption) { + if (currentOption !== this.firstOption) { + var index = this.filteredOptions.indexOf(currentOption); + return this.filteredOptions[index - 1]; + } + return this.lastOption; + } + + getNextOption(currentOption) { + if (currentOption !== this.lastOption) { + var index = this.filteredOptions.indexOf(currentOption); + return this.filteredOptions[index + 1]; + } + return this.firstOption; + } + + /* MENU DISPLAY METHODS */ + + doesOptionHaveFocus() { + return this.comboboxNode.getAttribute('aria-activedescendant') !== ''; + } + + isOpen() { + return this.listboxNode.style.display === 'block'; + } + + isClosed() { + return this.listboxNode.style.display !== 'block'; + } + + hasOptions() { + return this.filteredOptions.length; + } + + open() { + this.listboxNode.style.display = 'block'; + this.comboboxNode.setAttribute('aria-expanded', 'true'); + this.buttonNode.setAttribute('aria-expanded', 'true'); + } + + close(force) { + if (typeof force !== 'boolean') { + force = false; + } + + if ( + force || + (!this.comboboxHasVisualFocus && + !this.listboxHasVisualFocus && + !this.hasHover) + ) { + this.setCurrentOptionStyle(false); + this.listboxNode.style.display = 'none'; + this.comboboxNode.setAttribute('aria-expanded', 'false'); + this.buttonNode.setAttribute('aria-expanded', 'false'); + this.setActiveDescendant(false); + this.comboboxNode.parentNode.classList.add('focus'); + } + } + + /* combobox Events */ + + onComboboxKeyDown(event) { + var flag = false, + altKey = event.altKey; + + if (event.ctrlKey || event.shiftKey) { + return; + } + + switch (event.key) { + case 'Enter': + if (this.listboxHasVisualFocus) { + this.setValue(this.option.textContent); + } + this.close(true); + this.setVisualFocusCombobox(); + flag = true; + break; + + case 'Down': + case 'ArrowDown': + if (this.filteredOptions.length > 0) { + if (altKey) { + this.open(); + } else { + this.open(); + if ( + this.listboxHasVisualFocus || + (this.isBoth && this.filteredOptions.length > 1) + ) { + this.setOption(this.getNextOption(this.option), true); + this.setVisualFocusListbox(); + } else { + this.setOption(this.firstOption, true); + this.setVisualFocusListbox(); + } + } + } + flag = true; + break; + + case 'Up': + case 'ArrowUp': + if (this.hasOptions()) { + if (this.listboxHasVisualFocus) { + this.setOption(this.getPreviousOption(this.option), true); + } else { + this.open(); + if (!altKey) { + this.setOption(this.lastOption, true); + this.setVisualFocusListbox(); + } + } + } + flag = true; + break; + + case 'Esc': + case 'Escape': + if (this.isOpen()) { + this.close(true); + this.filter = this.comboboxNode.value; + this.filterOptions(); + this.setVisualFocusCombobox(); + } else { + this.setValue(''); + this.comboboxNode.value = ''; + } + this.option = null; + flag = true; + break; + + case 'Tab': + this.close(true); + if (this.listboxHasVisualFocus) { + if (this.option) { + this.setValue(this.option.textContent); + } + } + break; + + case 'Home': + this.comboboxNode.setSelectionRange(0, 0); + flag = true; + break; + + case 'End': + var length = this.comboboxNode.value.length; + this.comboboxNode.setSelectionRange(length, length); + flag = true; + break; + + default: + break; + } + + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + isPrintableCharacter(str) { + return str.length === 1 && str.match(/\S| /); + } + + onComboboxKeyUp(event) { + var flag = false, + option = null, + char = event.key; + + if (this.isPrintableCharacter(char)) { + this.filter += char; + } + + // this is for the case when a selection in the textbox has been deleted + if (this.comboboxNode.value.length < this.filter.length) { + this.filter = this.comboboxNode.value; + this.option = null; + this.filterOptions(); + } + + if (event.key === 'Escape' || event.key === 'Esc') { + return; + } + + switch (event.key) { + case 'Backspace': + this.setVisualFocusCombobox(); + this.setCurrentOptionStyle(false); + this.filter = this.comboboxNode.value; + this.option = null; + this.filterOptions(); + flag = true; + break; + + case 'Left': + case 'ArrowLeft': + case 'Right': + case 'ArrowRight': + case 'Home': + case 'End': + if (this.isBoth) { + this.filter = this.comboboxNode.value; + } else { + this.option = null; + this.setCurrentOptionStyle(false); + } + this.setVisualFocusCombobox(); + flag = true; + break; + + default: + if (this.isPrintableCharacter(char)) { + this.setVisualFocusCombobox(); + this.setCurrentOptionStyle(false); + flag = true; + + if (this.isList || this.isBoth) { + option = this.filterOptions(); + if (option) { + if (this.isClosed() && this.comboboxNode.value.length) { + this.open(); + } + + if ( + this.getLowercaseContent(option).indexOf( + this.comboboxNode.value.toLowerCase() + ) === 0 + ) { + this.option = option; + if (this.isBoth || this.listboxHasVisualFocus) { + this.setCurrentOptionStyle(option); + if (this.isBoth) { + this.setOption(option); + } + } + } else { + this.option = null; + this.setCurrentOptionStyle(false); + } + } else { + this.close(); + this.option = null; + this.setActiveDescendant(false); + } + } else if (this.comboboxNode.value.length) { + this.open(); + } + } + + break; + } + + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + onComboboxClick() { + if (this.isOpen()) { + this.close(true); + } else { + this.open(); + } + } + + onComboboxFocus() { + this.filter = this.comboboxNode.value; + this.filterOptions(); + this.setVisualFocusCombobox(); + this.option = null; + this.setCurrentOptionStyle(null); + } + + onComboboxBlur() { + this.removeVisualFocusAll(); + } + + onBackgroundPointerUp(event) { + if ( + !this.comboboxNode.contains(event.target) && + !this.listboxNode.contains(event.target) && + !this.buttonNode.contains(event.target) + ) { + this.comboboxHasVisualFocus = false; + this.setCurrentOptionStyle(null); + this.removeVisualFocusAll(); + setTimeout(this.close.bind(this, true), 300); + } + } + + onButtonClick() { + if (this.isOpen()) { + this.close(true); + } else { + this.open(); + } + this.comboboxNode.focus(); + this.setVisualFocusCombobox(); + } + + /* Listbox Events */ + + onListboxPointerover() { + this.hasHover = true; + } + + onListboxPointerout() { + this.hasHover = false; + setTimeout(this.close.bind(this, false), 300); + } + + // Listbox Option Events + + onOptionClick(event) { + this.comboboxNode.value = event.target.textContent; + this.close(true); + } + + onOptionPointerover() { + this.hasHover = true; + this.open(); + } + + onOptionPointerout() { + this.hasHover = false; + setTimeout(this.close.bind(this, false), 300); + } +} + +// Initialize comboboxes + +window.addEventListener('load', function () { + var comboboxes = document.querySelectorAll('.combobox-list'); + + for (var i = 0; i < comboboxes.length; i++) { + var combobox = comboboxes[i]; + var comboboxNode = combobox.querySelector('input'); + var buttonNode = combobox.querySelector('button'); + var listboxNode = combobox.querySelector('[role="listbox"]'); + new ComboboxAutocomplete(comboboxNode, buttonNode, listboxNode); + } +}); diff --git a/lib/widgets/date_picker.js b/lib/widgets/date_picker.js new file mode 100644 index 0000000..56e92fa --- /dev/null +++ b/lib/widgets/date_picker.js @@ -0,0 +1,866 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + * + * File: DatePickerDialog.js + */ + +'use strict'; + +class DatePickerDialog { + constructor(cdp) { + this.buttonLabelChoose = 'Choose Date'; + this.buttonLabelChange = 'Change 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.textboxNode = 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.lastDate = -1; + + this.isMouseDownOnBackground = false; + + this.textboxNode.addEventListener( + 'blur', + this.setDateForButtonLabel.bind(this) + ); + + this.buttonNode.addEventListener( + 'keydown', + this.handleButtonKeydown.bind(this) + ); + this.buttonNode.addEventListener( + 'click', + this.handleButtonClick.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( + 'pointerup', + this.handleBackgroundMouseUp.bind(this), + true + ); + + // Create Grid of Dates + + this.tbodyNode.innerHTML = ''; + for (let i = 0; i < 6; i++) { + const row = this.tbodyNode.insertRow(i); + this.lastRowNode = row; + for (let j = 0; j < 7; j++) { + const cell = document.createElement('td'); + + cell.tabIndex = -1; + cell.addEventListener('click', this.handleDayClick.bind(this)); + cell.addEventListener('keydown', this.handleDayKeyDown.bind(this)); + cell.addEventListener('focus', this.handleDayFocus.bind(this)); + + cell.textContent = '-1'; + + row.appendChild(cell); + this.days.push(cell); + } + } + + this.updateGrid(); + this.close(false); + this.setDateForButtonLabel(); + } + + isSameDay(day1, day2) { + return ( + day1.getFullYear() == day2.getFullYear() && + day1.getMonth() == day2.getMonth() && + day1.getDate() == day2.getDate() + ); + } + + isNotSameMonth(day1, day2) { + return ( + day1.getFullYear() != day2.getFullYear() || + day1.getMonth() != day2.getMonth() + ); + } + + updateGrid() { + const fd = this.focusDay; + + this.monthYearNode.textContent = + this.monthLabels[fd.getMonth()] + ' ' + fd.getFullYear(); + + let firstDayOfMonth = new Date(fd.getFullYear(), fd.getMonth(), 1); + let dayOfWeek = firstDayOfMonth.getDay(); + + firstDayOfMonth.setDate(firstDayOfMonth.getDate() - dayOfWeek); + + const d = new Date(firstDayOfMonth); + + for (let i = 0; i < this.days.length; i++) { + const 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 dates are disabled (e.g. in next month) + if (i === 35) { + if (flag) { + this.lastRowNode.style.visibility = 'hidden'; + } else { + this.lastRowNode.style.visibility = 'visible'; + } + } + } + } + + updateDate(domNode, disable, day, selected) { + let d = day.getDate().toString(); + if (day.getDate() <= 9) { + d = '0' + d; + } + + let m = day.getMonth() + 1; + if (day.getMonth() < 9) { + m = '0' + m; + } + + domNode.tabIndex = -1; + domNode.removeAttribute('aria-selected'); + domNode.setAttribute('data-date', day.getFullYear() + '-' + m + '-' + d); + + if (disable) { + domNode.classList.add('disabled'); + domNode.textContent = ''; + } else { + domNode.classList.remove('disabled'); + domNode.textContent = day.getDate(); + if (selected) { + domNode.setAttribute('aria-selected', 'true'); + domNode.tabIndex = 0; + } + } + } + + moveFocusToDay(day) { + const d = this.focusDay; + + this.focusDay = day; + + if ( + d.getMonth() != this.focusDay.getMonth() || + d.getFullYear() != this.focusDay.getFullYear() + ) { + this.updateGrid(); + } + this.setFocusDay(); + } + + setFocusDay(flag) { + if (typeof flag !== 'boolean') { + flag = true; + } + + for (let i = 0; i < this.days.length; i++) { + const dayNode = this.days[i]; + const day = this.getDayFromDataDateAttribute(dayNode); + + dayNode.tabIndex = -1; + if (this.isSameDay(day, this.focusDay)) { + dayNode.tabIndex = 0; + if (flag) { + dayNode.focus(); + } + } + } + } + + open() { + this.dialogNode.style.display = 'block'; + this.dialogNode.style.zIndex = 2; + + this.getDateFromTextbox(); + this.updateGrid(); + this.lastDate = this.focusDay.getDate(); + } + + isOpen() { + return window.getComputedStyle(this.dialogNode).display !== 'none'; + } + + close(flag) { + if (typeof flag !== 'boolean') { + // Default is to move focus to combobox + flag = true; + } + + this.setMessage(''); + this.dialogNode.style.display = 'none'; + + if (flag) { + this.buttonNode.focus(); + } + } + + changeMonth(currentDate, numMonths) { + const getDays = (year, month) => new Date(year, month, 0).getDate(); + + const isPrev = numMonths < 0; + const numYears = Math.trunc(Math.abs(numMonths) / 12); + numMonths = Math.abs(numMonths) % 12; + + const newYear = isPrev + ? currentDate.getFullYear() - numYears + : currentDate.getFullYear() + numYears; + + const newMonth = isPrev + ? currentDate.getMonth() - numMonths + : currentDate.getMonth() + numMonths; + + const newDate = new Date(newYear, newMonth, 1); + + const daysInMonth = getDays(newDate.getFullYear(), newDate.getMonth() + 1); + + // If lastDat is not initialized set to current date + this.lastDate = this.lastDate ? this.lastDate : currentDate.getDate(); + + if (this.lastDate > daysInMonth) { + newDate.setDate(daysInMonth); + } else { + newDate.setDate(this.lastDate); + } + + return newDate; + } + + moveToNextYear() { + this.focusDay = this.changeMonth(this.focusDay, 12); + this.updateGrid(); + } + + moveToPreviousYear() { + this.focusDay = this.changeMonth(this.focusDay, -12); + this.updateGrid(); + } + + moveToNextMonth() { + this.focusDay = this.changeMonth(this.focusDay, 1); + this.updateGrid(); + } + + moveToPreviousMonth() { + this.focusDay = this.changeMonth(this.focusDay, -1); + this.updateGrid(); + } + + moveFocusToNextDay() { + const d = new Date(this.focusDay); + d.setDate(d.getDate() + 1); + this.lastDate = d.getDate(); + this.moveFocusToDay(d); + } + + moveFocusToNextWeek() { + const d = new Date(this.focusDay); + d.setDate(d.getDate() + 7); + this.lastDate = d.getDate(); + this.moveFocusToDay(d); + } + + moveFocusToPreviousDay() { + const d = new Date(this.focusDay); + d.setDate(d.getDate() - 1); + this.lastDate = d.getDate(); + this.moveFocusToDay(d); + } + + moveFocusToPreviousWeek() { + const d = new Date(this.focusDay); + d.setDate(d.getDate() - 7); + this.lastDate = d.getDate(); + this.moveFocusToDay(d); + } + + moveFocusToFirstDayOfWeek() { + const d = new Date(this.focusDay); + d.setDate(d.getDate() - d.getDay()); + this.lastDate = d.getDate(); + this.moveFocusToDay(d); + } + + moveFocusToLastDayOfWeek() { + const d = new Date(this.focusDay); + d.setDate(d.getDate() + (6 - d.getDay())); + this.lastDate = d.getDate(); + this.moveFocusToDay(d); + } + + // Day methods + + isDayDisabled(domNode) { + return domNode.classList.contains('disabled'); + } + + getDayFromDataDateAttribute(domNode) { + const parts = domNode.getAttribute('data-date').split('-'); + return new Date(parts[0], parseInt(parts[1]) - 1, parts[2]); + } + + // Textbox methods + + setTextboxDate(domNode) { + let d = this.focusDay; + + if (domNode) { + d = this.getDayFromDataDateAttribute(domNode); + // updated aria-selected + this.days.forEach((day) => + day === domNode + ? day.setAttribute('aria-selected', 'true') + : day.removeAttribute('aria-selected') + ); + } + + this.textboxNode.value = + d.getMonth() + 1 + '/' + d.getDate() + '/' + d.getFullYear(); + this.setDateForButtonLabel(); + } + + getDateFromTextbox() { + const parts = this.textboxNode.value.split('/'); + const month = parseInt(parts[0]); + const day = parseInt(parts[1]); + let year = parseInt(parts[2]); + + if ( + parts.length === 3 && + Number.isInteger(month) && + Number.isInteger(day) && + Number.isInteger(year) + ) { + if (year < 100) { + year = 2000 + year; + } + this.focusDay = new Date(year, month - 1, day); + 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); + } + } + + setDateForButtonLabel() { + const parts = this.textboxNode.value.split('/'); + + if ( + parts.length === 3 && + Number.isInteger(parseInt(parts[0])) && + Number.isInteger(parseInt(parts[1])) && + Number.isInteger(parseInt(parts[2])) + ) { + const day = new Date( + parseInt(parts[2]), + parseInt(parts[0]) - 1, + parseInt(parts[1]) + ); + + let label = this.buttonLabelChange; + label += ', ' + this.dayLabels[day.getDay()]; + label += ' ' + this.monthLabels[day.getMonth()]; + label += ' ' + day.getDate(); + label += ', ' + day.getFullYear(); + this.buttonNode.setAttribute('aria-label', label); + } else { + // If not a valid date, initialize with "Choose Date" + this.buttonNode.setAttribute('aria-label', this.buttonLabelChoose); + } + } + + setMessage(str) { + function setMessageDelayed() { + this.messageNode.textContent = str; + } + + if (str !== this.lastMessage) { + setTimeout(setMessageDelayed.bind(this), 200); + this.lastMessage = str; + } + } + + // Event handlers + + handleOkButton(event) { + let 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.setTextboxDate(); + this.close(); + flag = true; + break; + + default: + break; + } + + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + handleCancelButton(event) { + let 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(); + } + } + + handleNextYearButton(event) { + let 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(); + } + } + + handlePreviousYearButton(event) { + let 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(); + } + } + + handleNextMonthButton(event) { + let 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(); + } + } + + handlePreviousMonthButton(event) { + let 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(); + } + } + + handleDayKeyDown(event) { + let flag = false; + + switch (event.key) { + case 'Esc': + case 'Escape': + this.close(); + break; + + case ' ': + this.setTextboxDate(event.currentTarget); + flag = true; + break; + + case 'Enter': + this.setTextboxDate(event.currentTarget); + this.close(); + flag = true; + 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(); + } + } + + handleDayClick(event) { + if (!this.isDayDisabled(event.currentTarget) && event.which !== 3) { + this.setTextboxDate(event.currentTarget); + this.close(); + } + + event.stopPropagation(); + event.preventDefault(); + } + + handleDayFocus() { + this.setMessage(this.messageCursorKeys); + } + + handleButtonKeydown(event) { + if (event.key === 'Enter' || event.key === ' ') { + this.open(); + this.setFocusDay(); + + event.stopPropagation(); + event.preventDefault(); + } + } + + handleButtonClick(event) { + if (this.isOpen()) { + this.close(); + } else { + this.open(); + this.setFocusDay(); + } + + event.stopPropagation(); + event.preventDefault(); + } + + handleBackgroundMouseUp(event) { + if ( + !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 () { + const datePickers = document.querySelectorAll('.datepicker'); + + datePickers.forEach(function (dp) { + new DatePickerDialog(dp); + }); +}); diff --git a/lib/widgets/modal_dialogue.js b/lib/widgets/modal_dialogue.js new file mode 100644 index 0000000..743d36e --- /dev/null +++ b/lib/widgets/modal_dialogue.js @@ -0,0 +1,464 @@ +/* + * This content is licensed according to the W3C Software License at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document + */ + +'use strict'; + +var aria = aria || {}; + +aria.Utils = aria.Utils || {}; + +(function () { + /* + * When util functions move focus around, set this true so the focus listener + * can ignore the events. + */ + aria.Utils.IgnoreUtilFocusChanges = false; + + aria.Utils.dialogOpenClass = 'has-dialog'; + + /** + * @description Set focus on descendant nodes until the first focusable element is + * found. + * @param element + * DOM node for which to find the first focusable descendant. + * @returns {boolean} + * true if a focusable element is found and focus is set. + */ + aria.Utils.focusFirstDescendant = function (element) { + for (var i = 0; i < element.childNodes.length; i++) { + var child = element.childNodes[i]; + if ( + aria.Utils.attemptFocus(child) || + aria.Utils.focusFirstDescendant(child) + ) { + return true; + } + } + return false; + }; // end focusFirstDescendant + + /** + * @description Find the last descendant node that is focusable. + * @param element + * DOM node for which to find the last focusable descendant. + * @returns {boolean} + * true if a focusable element is found and focus is set. + */ + aria.Utils.focusLastDescendant = function (element) { + for (var i = element.childNodes.length - 1; i >= 0; i--) { + var child = element.childNodes[i]; + if ( + aria.Utils.attemptFocus(child) || + aria.Utils.focusLastDescendant(child) + ) { + return true; + } + } + return false; + }; // end focusLastDescendant + + /** + * @description Set Attempt to set focus on the current node. + * @param element + * The node to attempt to focus on. + * @returns {boolean} + * true if element is focused. + */ + aria.Utils.attemptFocus = function (element) { + if (!aria.Utils.isFocusable(element)) { + return false; + } + + aria.Utils.IgnoreUtilFocusChanges = true; + try { + element.focus(); + } catch (e) { + // continue regardless of error + } + aria.Utils.IgnoreUtilFocusChanges = false; + return document.activeElement === element; + }; // end attemptFocus + + /* Modals can open modals. Keep track of them with this array. */ + aria.OpenDialogList = aria.OpenDialogList || new Array(0); + + /** + * @returns {object|void} the last opened dialog (the current dialog) + */ + aria.getCurrentDialog = function () { + if (aria.OpenDialogList && aria.OpenDialogList.length) { + return aria.OpenDialogList[aria.OpenDialogList.length - 1]; + } + }; + + aria.closeCurrentDialog = function () { + var currentDialog = aria.getCurrentDialog(); + if (currentDialog) { + currentDialog.close(); + return true; + } + + return false; + }; + + aria.handleEscape = function (event) { + var key = event.which || event.keyCode; + + if (key === aria.KeyCode.ESC && aria.closeCurrentDialog()) { + event.stopPropagation(); + } + }; + + document.addEventListener('keyup', aria.handleEscape); + + /** + * @class + * @description Dialog object providing modal focus management. + * + * Assumptions: The element serving as the dialog container is present in the + * DOM and hidden. The dialog container has role='dialog'. + * @param dialogId + * The ID of the element serving as the dialog container. + * @param focusAfterClosed + * Either the DOM node or the ID of the DOM node to focus when the + * dialog closes. + * @param focusFirst + * Optional parameter containing either the DOM node or the ID of the + * DOM node to focus when the dialog opens. If not specified, the + * first focusable element in the dialog will receive focus. + */ + aria.Dialog = function (dialogId, focusAfterClosed, focusFirst) { + this.dialogNode = document.getElementById(dialogId); + if (this.dialogNode === null) { + throw new Error('No element found with id="' + dialogId + '".'); + } + + var validRoles = ['dialog', 'alertdialog']; + var isDialog = (this.dialogNode.getAttribute('role') || '') + .trim() + .split(/\s+/g) + .some(function (token) { + return validRoles.some(function (role) { + return token === role; + }); + }); + if (!isDialog) { + throw new Error( + 'Dialog() requires a DOM element with ARIA role of dialog or alertdialog.' + ); + } + + // Wrap in an individual backdrop element if one doesn't exist + // Native elements use the ::backdrop pseudo-element, which + // works similarly. + var backdropClass = 'dialog-backdrop'; + if (this.dialogNode.parentNode.classList.contains(backdropClass)) { + this.backdropNode = this.dialogNode.parentNode; + } else { + this.backdropNode = document.createElement('div'); + this.backdropNode.className = backdropClass; + this.dialogNode.parentNode.insertBefore( + this.backdropNode, + this.dialogNode + ); + this.backdropNode.appendChild(this.dialogNode); + } + this.backdropNode.classList.add('active'); + + // Disable scroll on the body element + document.body.classList.add(aria.Utils.dialogOpenClass); + + if (typeof focusAfterClosed === 'string') { + this.focusAfterClosed = document.getElementById(focusAfterClosed); + } else if (typeof focusAfterClosed === 'object') { + this.focusAfterClosed = focusAfterClosed; + } else { + throw new Error( + 'the focusAfterClosed parameter is required for the aria.Dialog constructor.' + ); + } + + if (typeof focusFirst === 'string') { + this.focusFirst = document.getElementById(focusFirst); + } else if (typeof focusFirst === 'object') { + this.focusFirst = focusFirst; + } else { + this.focusFirst = null; + } + + // Bracket the dialog node with two invisible, focusable nodes. + // While this dialog is open, we use these to make sure that focus never + // leaves the document even if dialogNode is the first or last node. + var preDiv = document.createElement('div'); + this.preNode = this.dialogNode.parentNode.insertBefore( + preDiv, + this.dialogNode + ); + this.preNode.tabIndex = 0; + var postDiv = document.createElement('div'); + this.postNode = this.dialogNode.parentNode.insertBefore( + postDiv, + this.dialogNode.nextSibling + ); + this.postNode.tabIndex = 0; + + // If this modal is opening on top of one that is already open, + // get rid of the document focus listener of the open dialog. + if (aria.OpenDialogList.length > 0) { + aria.getCurrentDialog().removeListeners(); + } + + this.addListeners(); + aria.OpenDialogList.push(this); + this.clearDialog(); + this.dialogNode.className = 'default_dialog'; // make visible + + if (this.focusFirst) { + this.focusFirst.focus(); + } else { + aria.Utils.focusFirstDescendant(this.dialogNode); + } + + this.lastFocus = document.activeElement; + }; // end Dialog constructor + + aria.Dialog.prototype.clearDialog = function () { + Array.prototype.map.call( + this.dialogNode.querySelectorAll('input'), + function (input) { + input.value = ''; + } + ); + }; + + /** + * @description + * Hides the current top dialog, + * removes listeners of the top dialog, + * restore listeners of a parent dialog if one was open under the one that just closed, + * and sets focus on the element specified for focusAfterClosed. + */ + aria.Dialog.prototype.close = function () { + aria.OpenDialogList.pop(); + this.removeListeners(); + aria.Utils.remove(this.preNode); + aria.Utils.remove(this.postNode); + this.dialogNode.className = 'hidden'; + this.backdropNode.classList.remove('active'); + this.focusAfterClosed.focus(); + + // If a dialog was open underneath this one, restore its listeners. + if (aria.OpenDialogList.length > 0) { + aria.getCurrentDialog().addListeners(); + } else { + document.body.classList.remove(aria.Utils.dialogOpenClass); + } + }; // end close + + /** + * @description + * Hides the current dialog and replaces it with another. + * @param newDialogId + * ID of the dialog that will replace the currently open top dialog. + * @param newFocusAfterClosed + * Optional ID or DOM node specifying where to place focus when the new dialog closes. + * If not specified, focus will be placed on the element specified by the dialog being replaced. + * @param newFocusFirst + * Optional ID or DOM node specifying where to place focus in the new dialog when it opens. + * If not specified, the first focusable element will receive focus. + */ + aria.Dialog.prototype.replace = function ( + newDialogId, + newFocusAfterClosed, + newFocusFirst + ) { + aria.OpenDialogList.pop(); + this.removeListeners(); + aria.Utils.remove(this.preNode); + aria.Utils.remove(this.postNode); + this.dialogNode.className = 'hidden'; + this.backdropNode.classList.remove('active'); + + var focusAfterClosed = newFocusAfterClosed || this.focusAfterClosed; + new aria.Dialog(newDialogId, focusAfterClosed, newFocusFirst); + }; // end replace + + aria.Dialog.prototype.addListeners = function () { + document.addEventListener('focus', this.trapFocus, true); + }; // end addListeners + + aria.Dialog.prototype.removeListeners = function () { + document.removeEventListener('focus', this.trapFocus, true); + }; // end removeListeners + + aria.Dialog.prototype.trapFocus = function (event) { + if (aria.Utils.IgnoreUtilFocusChanges) { + return; + } + var currentDialog = aria.getCurrentDialog(); + if (currentDialog.dialogNode.contains(event.target)) { + currentDialog.lastFocus = event.target; + } else { + aria.Utils.focusFirstDescendant(currentDialog.dialogNode); + if (currentDialog.lastFocus == document.activeElement) { + aria.Utils.focusLastDescendant(currentDialog.dialogNode); + } + currentDialog.lastFocus = document.activeElement; + } + }; // end trapFocus + + window.openDialog = function (dialogId, focusAfterClosed, focusFirst) { + new aria.Dialog(dialogId, focusAfterClosed, focusFirst); + }; + + window.closeDialog = function (closeButton) { + var topDialog = aria.getCurrentDialog(); + if (topDialog.dialogNode.contains(closeButton)) { + topDialog.close(); + } + }; // end closeDialog + + window.replaceDialog = function ( + newDialogId, + newFocusAfterClosed, + newFocusFirst + ) { + var topDialog = aria.getCurrentDialog(); + if (topDialog.dialogNode.contains(document.activeElement)) { + topDialog.replace(newDialogId, newFocusAfterClosed, newFocusFirst); + } + }; // end replaceDialog +})(); +'use strict'; +/** + * @namespace aria + */ + +var aria = aria || {}; + +/** + * @description + * Key code constants + */ +aria.KeyCode = { + BACKSPACE: 8, + TAB: 9, + RETURN: 13, + SHIFT: 16, + ESC: 27, + SPACE: 32, + PAGE_UP: 33, + PAGE_DOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + DELETE: 46, +}; + +aria.Utils = aria.Utils || {}; + +// Polyfill src https://developer.mozilla.org/en-US/docs/Web/API/Element/matches +aria.Utils.matches = function (element, selector) { + if (!Element.prototype.matches) { + Element.prototype.matches = + Element.prototype.matchesSelector || + Element.prototype.mozMatchesSelector || + Element.prototype.msMatchesSelector || + Element.prototype.oMatchesSelector || + Element.prototype.webkitMatchesSelector || + function (s) { + var matches = element.parentNode.querySelectorAll(s); + var i = matches.length; + while (--i >= 0 && matches.item(i) !== this) { + // empty + } + return i > -1; + }; + } + + return element.matches(selector); +}; + +aria.Utils.remove = function (item) { + if (item.remove && typeof item.remove === 'function') { + return item.remove(); + } + if ( + item.parentNode && + item.parentNode.removeChild && + typeof item.parentNode.removeChild === 'function' + ) { + return item.parentNode.removeChild(item); + } + return false; +}; + +aria.Utils.isFocusable = function (element) { + if (element.tabIndex < 0) { + return false; + } + + if (element.disabled) { + return false; + } + + switch (element.nodeName) { + case 'A': + return !!element.href && element.rel != 'ignore'; + case 'INPUT': + return element.type != 'hidden'; + case 'BUTTON': + case 'SELECT': + case 'TEXTAREA': + return true; + default: + return false; + } +}; + +aria.Utils.getAncestorBySelector = function (element, selector) { + if (!aria.Utils.matches(element, selector + ' ' + element.tagName)) { + // Element is not inside an element that matches selector + return null; + } + + // Move up the DOM tree until a parent matching the selector is found + var currentNode = element; + var ancestor = null; + while (ancestor === null) { + if (aria.Utils.matches(currentNode.parentNode, selector)) { + ancestor = currentNode.parentNode; + } else { + currentNode = currentNode.parentNode; + } + } + + return ancestor; +}; + +aria.Utils.hasClass = function (element, className) { + return new RegExp('(\\s|^)' + className + '(\\s|$)').test(element.className); +}; + +aria.Utils.addClass = function (element, className) { + if (!aria.Utils.hasClass(element, className)) { + element.className += ' ' + className; + } +}; + +aria.Utils.removeClass = function (element, className) { + var classRegex = new RegExp('(\\s|^)' + className + '(\\s|$)'); + element.className = element.className.replace(classRegex, ' ').trim(); +}; + +aria.Utils.bindMethods = function (object /* , ...methodNames */) { + var methodNames = Array.prototype.slice.call(arguments, 1); + methodNames.forEach(function (method) { + object[method] = object[method].bind(object); + }); +};