HTML preprocessors can make writing HTML more powerful or convenient. For instance, Markdown is designed to be easier to write and read for text documents and you could write a loop in Pug.
In CodePen, whatever you write in the HTML editor is what goes within the <body>
tags in a basic HTML5 template. So you don't have access to higher-up elements like the <html>
tag. If you want to add classes there that can affect the whole document, this is the place to do it.
In CodePen, whatever you write in the HTML editor is what goes within the <body>
tags in a basic HTML5 template. If you need things in the <head>
of the document, put that code here.
The resource you are linking to is using the 'http' protocol, which may not work when the browser is using https.
CSS preprocessors help make authoring CSS easier. All of them offer things like variables and mixins to provide convenient abstractions.
It's a common practice to apply CSS to a page that styles elements such that they are consistent across all browsers. We offer two of the most popular choices: normalize.css and a reset. Or, choose Neither and nothing will be applied.
To get the best cross-browser support, it is a common practice to apply vendor prefixes to CSS properties and values that require them to work. For instance -webkit-
or -moz-
.
We offer two popular choices: Autoprefixer (which processes your CSS server-side) and -prefix-free (which applies prefixes via a script, client-side).
Any URLs added here will be added as <link>
s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.
You can apply CSS to your Pen from any stylesheet on the web. Just put a URL to it here and we'll apply it, in the order you have them, before the CSS in the Pen itself.
You can also link to another Pen here (use the .css
URL Extension) and we'll pull the CSS from that Pen and include it. If it's using a matching preprocessor, use the appropriate URL Extension and we'll combine the code before preprocessing, so you can use the linked Pen as a true dependency.
JavaScript preprocessors can help make authoring JavaScript easier and more convenient.
Babel includes JSX processing.
Any URL's added here will be added as <script>
s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.
You can apply a script from anywhere on the web to your Pen. Just put a URL to it here and we'll add it, in the order you have them, before the JavaScript in the Pen itself.
If the script you link to has the file extension of a preprocessor, we'll attempt to process it before applying.
You can also link to another Pen here, and we'll pull the JavaScript from that Pen and include it. If it's using a matching preprocessor, we'll combine the code before preprocessing, so you can use the linked Pen as a true dependency.
Search for and use JavaScript packages from npm here. By selecting a package, an import
statement will be added to the top of the JavaScript editor for this package.
Using packages here is powered by esm.sh, which makes packages from npm not only available on a CDN, but prepares them for native JavaScript ESM usage.
All packages are different, so refer to their docs for how they work.
If you're using React / ReactDOM, make sure to turn on Babel for the JSX processing.
If active, Pens will autosave every 30 seconds after being saved once.
If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.
If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.
Visit your global Editor Settings.
<main>
<h1>Testing <code>aria-readonly</code></h1>
<p>
Used in the post <cite><a href="https://adrianroselli.com/2022/11/brief-note-on-aria-readonly-support-html.html" target="_top">Brief Note on <code>aria-readonly</code> Support</a></cite>.
</p>
<p>
These controls, with the exception of #6a (the second ARIA checkbox) in no way programmatically prevent interaction. There is no script to limit their function (again, except the ARIA checkbox).
</p>
<form autocomplete="off" method="get">
<h2>1. Uses <code>aria-readonly</code> with <code>radiogroup</code> Role on a <code><fieldset></code></h2>
<fieldset aria-readonly="true" role="radiogroup">
<legend>Sauce</legend>
<div>
<input type="radio" name="sauce" id="sauce1" value="fish"> <label for="sauce1">Fish</label>
</div>
<div>
<input type="radio" name="sauce" id="sauce2" value="tomato" checked> <label for="sauce2">Tomato</label>
</div>
<div>
<input type="radio" name="sauce" id="sauce3" value="bearnaise"> <label for="sauce3">Béarnaise</label>
</div>
</fieldset>
<input type="submit" value="Choose">
</form>
<form autocomplete="off" method="get">
<h2>2. Uses <code>aria-readonly</code> with <code>radiogroup</code> Role</h2>
<div aria-readonly="true" role="radiogroup" aria-labelledby="GrpLbl">
<div id="GrpLbl">Cheese</div>
<div>
<input type="radio" name="cheese" id="cheese1" value="fish"> <label for="cheese1">Swiss</label>
</div>
<div>
<input type="radio" name="cheese" id="cheese2" value="tomato" checked> <label for="cheese2">Unpasteurized English stilton</label>
</div>
<div>
<input type="radio" name="cheese" id="cheese3" value="bearnaise"> <label for="cheese3">Cheddar</label>
</div>
</div>
<input type="submit" value="Choose">
</form>
<form autocomplete="off" method="get">
<h2>3. APG Combobox with <code>aria-readonly</code> on the <code>combobox</code> and <code>listbox</code> Roles</h2>
<label id="combo1-label" class="combo-label">Favorite Fruit</label>
<div class="combo js-select">
<div aria-controls="listbox1" aria-expanded="false" aria-haspopup="listbox" aria-labelledby="combo1-label" id="combo1" class="combo-input" role="combobox" tabindex="0" aria-readonly="true">
</div>
<div class="combo-menu" role="listbox" id="listbox1" aria-labelledby="combo1-label" tabindex="-1" aria-readonly="true">
</div>
</div>
</form>
<form autocomplete="off" method="get">
<h2>4. APG Listbox with <code>aria-readonly</code></h2>
<p>Choose your favorite transuranic element (actinide or transactinide).</p>
<div class="listbox-area">
<div>
<span id="ss_elem" class="listbox-label">Transuranium elements:</span>
<ul id="ss_elem_list" tabindex="0" role="listbox" aria-labelledby="ss_elem" aria-readonly="true">
<li id="ss_elem_Np" role="option">Neptunium</li>
<li id="ss_elem_Pu" role="option">Plutonium</li>
<li id="ss_elem_Am" role="option">Americium</li>
<li id="ss_elem_Cm" role="option">Curium</li>
<li id="ss_elem_Bk" role="option">Berkelium</li>
<li id="ss_elem_Cf" role="option">Californium</li>
<li id="ss_elem_Es" role="option">Einsteinium</li>
<li id="ss_elem_Fm" role="option">Fermium</li>
<li id="ss_elem_Md" role="option">Mendelevium</li>
<li id="ss_elem_No" role="option">Nobelium</li>
<li id="ss_elem_Lr" role="option">Lawrencium</li>
<li id="ss_elem_Rf" role="option">Rutherfordium</li>
<li id="ss_elem_Db" role="option">Dubnium</li>
<li id="ss_elem_Sg" role="option">Seaborgium</li>
<li id="ss_elem_Bh" role="option">Bohrium</li>
<li id="ss_elem_Hs" role="option">Hassium</li>
<li id="ss_elem_Mt" role="option">Meitnerium</li>
<li id="ss_elem_Ds" role="option">Darmstadtium</li>
<li id="ss_elem_Rg" role="option">Roentgenium</li>
<li id="ss_elem_Cn" role="option">Copernicium</li>
<li id="ss_elem_Nh" role="option">Nihonium</li>
<li id="ss_elem_Fl" role="option">Flerovium</li>
<li id="ss_elem_Mc" role="option">Moscovium</li>
<li id="ss_elem_Lv" role="option">Livermorium</li>
<li id="ss_elem_Ts" role="option">Tennessine</li>
<li id="ss_elem_Og" role="option">Oganesson</li>
</ul>
</div>
</div>
</form>
<form autocomplete="off" method="get">
<h2>5. Native checkbox with <code>aria-readonly</code></h2>
<input type="checkbox" id="signUp" value="yes" aria-readonly="true" checked>
<label for="signUp">
Please sign me up for your newsletter.
</label>
</form>
<form autocomplete="off" method="get">
<h2>6. <code>checkbox</code> Role with <code>aria-readonly</code></h2>
<div role="checkbox" tabindex="0" aria-checked="true" id="Chk01" onclick="toggleChk(this.id);" onkeyup="if(window.event.keyCode == 32){toggleChk(this.id)};" aria-readonly="true">
Please sign me up for your newsletter.
</div>
</form>
<form autocomplete="off" method="get">
<h2>6a. <code>checkbox</code> Role with <code>aria-readonly</code> and Checking Disallowed</h2>
<div role="checkbox" tabindex="0" aria-checked="true" id="Chk02" onclick="toggleChk(this.id);" onkeyup="if(window.event.keyCode == 32){toggleChk(this.id)};" aria-readonly="true">
Definitely sign me up for your other newsletter.
</div>
</form>
</main>
body {
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto,
Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
line-height: 1.4;
/* line-height: 1.5; */
/* letter-spacing: 0.12em; */
/* word-spacing: 0.16em; */
}
h1, h2, h3 {
line-height: 1.2;
}
form {
flex: 1 0 15em;
margin: 1em;
}
div[role="radiogroup"] {
padding: .25em .5em .5em .5em;
border: .1em solid #ccc;
}
div[role="radiogroup"] > div:first-child {
font-weight: bold;
}
div[role="checkbox"] {
padding: .2em .5em;
position: relative;
}
div[role="checkbox"]:focus {
outline: .2em dotted currentColor;
}
div[role="checkbox"]::before {
content: "";
display: inline-block;
margin-bottom: -0.25em;
width: 1em;
height: 1em;
border: .1em solid currentColor;
border-radius: .25em;
}
div[role="checkbox"][aria-checked="true"]::after {
content: "";
display: block;
position: absolute;
top: .1em;
left: .75em;
width: .5em;
height: 1em;
border-right: .35em solid currentColor;
border-bottom: .35em solid currentColor;
transform: rotate(30deg);
}
.combo-menu[role="listbox"] {
margin-top: 0;
}
td, th {
min-width: 12em;
}
/* Standard Tables */
table {
margin: 1em 0;
border-collapse: collapse;
border: 0.1em solid #d6d6d6;
}
caption {
text-align: left;
font-style: italic;
padding: 0.25em 0.5em 0.5em 0.5em;
}
th,
td {
padding: 0.25em 0.5em 0.25em 1em;
vertical-align: text-top;
text-align: left;
text-indent: -0.5em;
}
th {
vertical-align: bottom;
background-color: #666;
color: #fff;
}
tr:nth-child(even) th[scope="row"] {
background-color: #f2f2f2;
}
tr:nth-child(odd) th[scope="row"] {
background-color: #fff;
}
tr {
border-top: .05em solid #333;
border-top: .05em solid #333;
}
/* Responsive Tables */
[role="region"][aria-labelledby][tabindex] {
overflow: auto;
border: 0.1em solid #d6d6d6;
}
[role="region"][aria-labelledby][tabindex]:focus {
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.5);
outline: 0.1em solid rgba(0, 0, 0, 0.1);
}
[role="region"][aria-labelledby][tabindex] table {
margin: 0;
border: none;
}
/* Scrolling Visual Cue */
[role="region"][aria-labelledby][tabindex] {
background: linear-gradient(to right, #fff 30%, rgba(255, 255, 255, 0)),
linear-gradient(to right, rgba(255, 255, 255, 0), #fff 70%) 0 100%,
radial-gradient(
farthest-side at 0% 50%,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0)
),
radial-gradient(
farthest-side at 100% 50%,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0)
)
0 100%;
background-repeat: no-repeat;
background-color: #fff;
background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%;
background-position: 0 0, 100%, 0 0, 100%;
background-attachment: local, local, scroll, scroll;
}
/* Fixed Headers */
th {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 2;
}
th[scope=row] {
position: -webkit-sticky;
position: sticky;
left: 0;
z-index: 1;
}
th[scope=row] {
vertical-align: top;
color: inherit;
background-color: inherit;
background: linear-gradient(90deg, transparent 0%, transparent calc(100% - .05em), #d6d6d6 calc(100% - .05em), #d6d6d6 100%);
}
table:nth-of-type(2) th:not([scope=row]):first-child {
left: 0;
z-index: 3;
background: linear-gradient(90deg, #666 0%, #666 calc(100% - .05em), #ccc calc(100% - .05em), #ccc 100%);
}
/* Confluence styles */
.good {
background-color: #E3FCEF;
}
.good::before {
content: "\02714\0fe0f ";
}
.caution {
background-color: #FFFAE6;
}
.caution::before {
content: "‽ ";
font-weight: bold;
font-size: 125%;
line-height: 0;
}
.buggy {
background-color: #FFEBE6;
}
.buggy::before {
content: "\0274c ";
}
/* APG styles */
.listbox-area {
display: grid;
grid-gap: 2em;
grid-template-columns: repeat(2, 1fr);
padding: 20px;
border: 1px solid #aaa;
border-radius: 4px;
background: #eee;
}
[role="listbox"] {
margin: 1em 0 0;
padding: 0;
min-height: 18em;
border: 1px solid #aaa;
background: white;
}
[role="listbox"]#ss_elem_list {
position: relative;
max-height: 18em;
overflow-y: auto;
}
[role="listbox"] + *,
.listbox-label + * {
margin-top: 1em;
}
[role="group"] {
margin: 0;
padding: 0;
}
[role="group"] > [role="presentation"] {
display: block;
margin: 0;
padding: 0 0.5em;
font-weight: bold;
line-height: 2;
background-color: #ccc;
}
[role="option"] {
position: relative;
display: block;
padding: 0 1em 0 1.5em;
line-height: 1.8em;
}
[role="option"].focused {
background: #bde4ff;
}
[role="option"][aria-selected="true"]::before {
position: absolute;
left: 0.5em;
content: "✓";
}
button[aria-haspopup="listbox"] {
position: relative;
padding: 5px 10px;
width: 150px;
border-radius: 0;
text-align: left;
}
button[aria-haspopup="listbox"]::after {
position: absolute;
right: 5px;
top: 10px;
width: 0;
height: 0;
border: 8px solid transparent;
border-top-color: currentcolor;
border-bottom: 0;
content: "";
}
button[aria-haspopup="listbox"][aria-expanded="true"]::after {
position: absolute;
right: 5px;
top: 10px;
width: 0;
height: 0;
border: 8px solid transparent;
border-top: 0;
border-bottom-color: currentcolor;
content: "";
}
button[aria-haspopup="listbox"] + [role="listbox"] {
position: absolute;
margin: 0;
width: 9.5em;
max-height: 10em;
border-top: 0;
overflow-y: auto;
}
[role="toolbar"] {
display: flex;
}
[role="toolbar"] > * {
border: 1px solid #aaa;
background: #ccc;
}
[role="toolbar"] > [aria-disabled="false"]:focus {
background-color: #eee;
}
button {
font-size: inherit;
}
button[aria-disabled="true"] {
opacity: 0.5;
}
.move-right-btn::after {
content: " →";
}
.move-left-btn::before {
content: "← ";
}
.annotate {
color: #366ed4;
font-style: italic;
}
.hidden {
display: none;
}
.offscreen {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px);
clip: rect(1px, 1px, 1px, 1px);
font-size: 14px;
white-space: nowrap;
}
/* APG combobox */
.combo *,
.combo *::before,
.combo *::after {
box-sizing: border-box;
}
.combo {
display: block;
margin-bottom: 1.5em;
max-width: 400px;
position: relative;
}
.combo::after {
border-bottom: 2px solid rgb(0 0 0 / 75%);
border-right: 2px solid rgb(0 0 0 / 75%);
content: "";
display: block;
height: 12px;
pointer-events: none;
position: absolute;
right: 16px;
top: 50%;
transform: translate(0, -65%) rotate(45deg);
width: 12px;
}
.combo-input {
background-color: #f5f5f5;
border: 2px solid rgb(0 0 0 / 75%);
border-radius: 4px;
display: block;
font-size: 1em;
min-height: calc(1.4em + 26px);
padding: 12px 16px 14px;
text-align: left;
width: 100%;
}
.open .combo-input {
border-radius: 4px 4px 0 0;
}
.combo-input:focus {
border-color: #0067b8;
box-shadow: 0 0 4px 2px #0067b8;
outline: 4px solid transparent;
}
.combo-label {
display: block;
font-size: 20px;
font-weight: 100;
margin-bottom: 0.25em;
}
.combo-menu {
background-color: #f5f5f5;
border: 1px solid rgb(0 0 0 / 75%);
border-radius: 0 0 4px 4px;
display: none;
max-height: 300px;
overflow-y: scroll;
left: 0;
position: absolute;
top: 100%;
width: 100%;
z-index: 100;
}
.open .combo-menu {
display: block;
}
.combo-option {
padding: 10px 12px 12px;
}
.combo-option:hover {
background-color: rgb(0 0 0 / 10%);
}
.combo-option.option-current {
outline: 3px solid #0067b8;
outline-offset: -3px;
}
.combo-option[aria-selected="true"] {
padding-right: 30px;
position: relative;
}
.combo-option[aria-selected="true"]::after {
border-bottom: 2px solid #000;
border-right: 2px solid #000;
content: "";
height: 16px;
position: absolute;
right: 15px;
top: 50%;
transform: translate(0, -50%) rotate(45deg);
width: 8px;
}
function toggleChk(btnID) {
var theButton = document.getElementById(btnID);
// if (theButton.getAttribute("aria-readonly") == "true") {
if (btnID == "Chk02") {
alert("You cannot change your mind.");
}else{
if (theButton.getAttribute("aria-checked") == "false") {
theButton.setAttribute("aria-checked", "true");
} else {
theButton.setAttribute("aria-checked", "false");
}
}
}
/*
* This content is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
*/
'use strict';
/**
* @namespace aria
*/
var aria = aria || {};
/**
* @class
* @description
* Listbox object representing the state and interactions for a listbox widget
* @param listboxNode
* The DOM node pointing to the listbox
*/
aria.Listbox = function (listboxNode) {
this.listboxNode = listboxNode;
this.activeDescendant = this.listboxNode.getAttribute(
'aria-activedescendant'
);
this.multiselectable = this.listboxNode.hasAttribute('aria-multiselectable');
this.moveUpDownEnabled = false;
this.siblingList = null;
this.startRangeIndex = 0;
this.upButton = null;
this.downButton = null;
this.moveButton = null;
this.keysSoFar = '';
this.handleFocusChange = function () {};
this.handleItemChange = function () {};
this.registerEvents();
};
/**
* @description
* Register events for the listbox interactions
*/
aria.Listbox.prototype.registerEvents = function () {
this.listboxNode.addEventListener('focus', this.setupFocus.bind(this));
this.listboxNode.addEventListener('keydown', this.checkKeyPress.bind(this));
this.listboxNode.addEventListener('click', this.checkClickItem.bind(this));
if (this.multiselectable) {
this.listboxNode.addEventListener(
'mousedown',
this.checkMouseDown.bind(this)
);
}
};
/**
* @description
* If there is no activeDescendant, focus on the first option
*/
aria.Listbox.prototype.setupFocus = function () {
if (this.activeDescendant) {
return;
}
};
/**
* @description
* Focus on the first option
*/
aria.Listbox.prototype.focusFirstItem = function () {
var firstItem = this.listboxNode.querySelector('[role="option"]');
if (firstItem) {
this.focusItem(firstItem);
}
};
/**
* @description
* Focus on the last option
*/
aria.Listbox.prototype.focusLastItem = function () {
var itemList = this.listboxNode.querySelectorAll('[role="option"]');
if (itemList.length) {
this.focusItem(itemList[itemList.length - 1]);
}
};
/**
* @description
* Handle various keyboard controls; UP/DOWN will shift focus; SPACE selects
* an item.
* @param evt
* The keydown event object
*/
aria.Listbox.prototype.checkKeyPress = function (evt) {
var key = evt.which || evt.keyCode;
var lastActiveId = this.activeDescendant;
var allOptions = this.listboxNode.querySelectorAll('[role="option"]');
var currentItem =
document.getElementById(this.activeDescendant) || allOptions[0];
var nextItem = currentItem;
if (!currentItem) {
return;
}
switch (key) {
case aria.KeyCode.PAGE_UP:
case aria.KeyCode.PAGE_DOWN:
if (this.moveUpDownEnabled) {
evt.preventDefault();
if (key === aria.KeyCode.PAGE_UP) {
this.moveUpItems();
} else {
this.moveDownItems();
}
}
break;
case aria.KeyCode.UP:
case aria.KeyCode.DOWN:
if (!this.activeDescendant) {
// focus first option if no option was previously focused, and perform no other actions
this.focusItem(currentItem);
break;
}
if (this.moveUpDownEnabled && evt.altKey) {
evt.preventDefault();
if (key === aria.KeyCode.UP) {
this.moveUpItems();
} else {
this.moveDownItems();
}
return;
}
if (key === aria.KeyCode.UP) {
nextItem = this.findPreviousOption(currentItem);
} else {
nextItem = this.findNextOption(currentItem);
}
if (nextItem && this.multiselectable && event.shiftKey) {
this.selectRange(this.startRangeIndex, nextItem);
}
if (nextItem) {
this.focusItem(nextItem);
evt.preventDefault();
}
break;
case aria.KeyCode.HOME:
evt.preventDefault();
this.focusFirstItem();
if (this.multiselectable && evt.shiftKey && evt.ctrlKey) {
this.selectRange(this.startRangeIndex, 0);
}
break;
case aria.KeyCode.END:
evt.preventDefault();
this.focusLastItem();
if (this.multiselectable && evt.shiftKey && evt.ctrlKey) {
this.selectRange(this.startRangeIndex, allOptions.length - 1);
}
break;
case aria.KeyCode.SHIFT:
this.startRangeIndex = this.getElementIndex(currentItem, allOptions);
break;
case aria.KeyCode.SPACE:
evt.preventDefault();
this.toggleSelectItem(nextItem);
break;
case aria.KeyCode.BACKSPACE:
case aria.KeyCode.DELETE:
case aria.KeyCode.RETURN:
if (!this.moveButton) {
return;
}
var keyshortcuts = this.moveButton.getAttribute('aria-keyshortcuts');
if (key === aria.KeyCode.RETURN && keyshortcuts.indexOf('Enter') === -1) {
return;
}
if (
(key === aria.KeyCode.BACKSPACE || key === aria.KeyCode.DELETE) &&
keyshortcuts.indexOf('Delete') === -1
) {
return;
}
evt.preventDefault();
var nextUnselected = nextItem.nextElementSibling;
while (nextUnselected) {
if (nextUnselected.getAttribute('aria-selected') != 'true') {
break;
}
nextUnselected = nextUnselected.nextElementSibling;
}
if (!nextUnselected) {
nextUnselected = nextItem.previousElementSibling;
while (nextUnselected) {
if (nextUnselected.getAttribute('aria-selected') != 'true') {
break;
}
nextUnselected = nextUnselected.previousElementSibling;
}
}
this.moveItems();
if (!this.activeDescendant && nextUnselected) {
this.focusItem(nextUnselected);
}
break;
case 65:
// handle control + A
if (this.multiselectable && (evt.ctrlKey || evt.metaKey)) {
evt.preventDefault();
this.selectRange(0, allOptions.length - 1);
break;
}
// fall through
default:
var itemToFocus = this.findItemToFocus(key);
if (itemToFocus) {
this.focusItem(itemToFocus);
}
break;
}
if (this.activeDescendant !== lastActiveId) {
this.updateScroll();
}
};
aria.Listbox.prototype.findItemToFocus = function (key) {
var itemList = this.listboxNode.querySelectorAll('[role="option"]');
var character = String.fromCharCode(key);
var searchIndex = 0;
if (!this.keysSoFar) {
for (var i = 0; i < itemList.length; i++) {
if (itemList[i].getAttribute('id') == this.activeDescendant) {
searchIndex = i;
}
}
}
this.keysSoFar += character;
this.clearKeysSoFarAfterDelay();
var nextMatch = this.findMatchInRange(
itemList,
searchIndex + 1,
itemList.length
);
if (!nextMatch) {
nextMatch = this.findMatchInRange(itemList, 0, searchIndex);
}
return nextMatch;
};
/* Return the index of the passed element within the passed array, or null if not found */
aria.Listbox.prototype.getElementIndex = function (option, options) {
var allOptions = Array.prototype.slice.call(options); // convert to array
var optionIndex = allOptions.indexOf(option);
return typeof optionIndex === 'number' ? optionIndex : null;
};
/* Return the next listbox option, if it exists; otherwise, returns null */
aria.Listbox.prototype.findNextOption = function (currentOption) {
var allOptions = Array.prototype.slice.call(
this.listboxNode.querySelectorAll('[role="option"]')
); // get options array
var currentOptionIndex = allOptions.indexOf(currentOption);
var nextOption = null;
if (currentOptionIndex > -1 && currentOptionIndex < allOptions.length - 1) {
nextOption = allOptions[currentOptionIndex + 1];
}
return nextOption;
};
/* Return the previous listbox option, if it exists; otherwise, returns null */
aria.Listbox.prototype.findPreviousOption = function (currentOption) {
var allOptions = Array.prototype.slice.call(
this.listboxNode.querySelectorAll('[role="option"]')
); // get options array
var currentOptionIndex = allOptions.indexOf(currentOption);
var previousOption = null;
if (currentOptionIndex > -1 && currentOptionIndex > 0) {
previousOption = allOptions[currentOptionIndex - 1];
}
return previousOption;
};
aria.Listbox.prototype.clearKeysSoFarAfterDelay = function () {
if (this.keyClear) {
clearTimeout(this.keyClear);
this.keyClear = null;
}
this.keyClear = setTimeout(
function () {
this.keysSoFar = '';
this.keyClear = null;
}.bind(this),
500
);
};
aria.Listbox.prototype.findMatchInRange = function (
list,
startIndex,
endIndex
) {
// Find the first item starting with the keysSoFar substring, searching in
// the specified range of items
for (var n = startIndex; n < endIndex; n++) {
var label = list[n].innerText;
if (label && label.toUpperCase().indexOf(this.keysSoFar) === 0) {
return list[n];
}
}
return null;
};
/**
* @description
* Check if an item is clicked on. If so, focus on it and select it.
* @param evt
* The click event object
*/
aria.Listbox.prototype.checkClickItem = function (evt) {
if (evt.target.getAttribute('role') !== 'option') {
return;
}
this.focusItem(evt.target);
this.toggleSelectItem(evt.target);
this.updateScroll();
if (this.multiselectable && evt.shiftKey) {
this.selectRange(this.startRangeIndex, evt.target);
}
};
/**
* Prevent text selection on shift + click for multi-select listboxes
*
* @param evt
*/
aria.Listbox.prototype.checkMouseDown = function (evt) {
if (
this.multiselectable &&
evt.shiftKey &&
evt.target.getAttribute('role') === 'option'
) {
evt.preventDefault();
}
};
/**
* @description
* Toggle the aria-selected value
* @param element
* The element to select
*/
aria.Listbox.prototype.toggleSelectItem = function (element) {
if (this.multiselectable) {
element.setAttribute(
'aria-selected',
element.getAttribute('aria-selected') === 'true' ? 'false' : 'true'
);
this.updateMoveButton();
}
};
/**
* @description
* Defocus the specified item
* @param element
* The element to defocus
*/
aria.Listbox.prototype.defocusItem = function (element) {
if (!element) {
return;
}
if (!this.multiselectable) {
element.removeAttribute('aria-selected');
}
element.classList.remove('focused');
};
/**
* @description
* Focus on the specified item
* @param element
* The element to focus
*/
aria.Listbox.prototype.focusItem = function (element) {
this.defocusItem(document.getElementById(this.activeDescendant));
if (!this.multiselectable) {
element.setAttribute('aria-selected', 'true');
}
element.classList.add('focused');
this.listboxNode.setAttribute('aria-activedescendant', element.id);
this.activeDescendant = element.id;
if (!this.multiselectable) {
this.updateMoveButton();
}
this.checkUpDownButtons();
this.handleFocusChange(element);
};
/**
* Helper function to check if a number is within a range; no side effects.
*
* @param index
* @param start
* @param end
* @returns {boolean}
*/
aria.Listbox.prototype.checkInRange = function (index, start, end) {
var rangeStart = start < end ? start : end;
var rangeEnd = start < end ? end : start;
return index >= rangeStart && index <= rangeEnd;
};
/**
* Select a range of options
*
* @param start
* @param end
*/
aria.Listbox.prototype.selectRange = function (start, end) {
// get start/end indices
var allOptions = this.listboxNode.querySelectorAll('[role="option"]');
var startIndex =
typeof start === 'number' ? start : this.getElementIndex(start, allOptions);
var endIndex =
typeof end === 'number' ? end : this.getElementIndex(end, allOptions);
for (var index = 0; index < allOptions.length; index++) {
var selected = this.checkInRange(index, startIndex, endIndex);
allOptions[index].setAttribute('aria-selected', selected + '');
}
this.updateMoveButton();
};
/**
* Check for selected options and update moveButton, if applicable
*/
aria.Listbox.prototype.updateMoveButton = function () {
if (!this.moveButton) {
return;
}
if (this.listboxNode.querySelector('[aria-selected="true"]')) {
this.moveButton.setAttribute('aria-disabled', 'false');
} else {
this.moveButton.setAttribute('aria-disabled', 'true');
}
};
/**
* Check if the selected option is in view, and scroll if not
*/
aria.Listbox.prototype.updateScroll = function () {
var selectedOption = document.getElementById(this.activeDescendant);
if (
selectedOption &&
this.listboxNode.scrollHeight > this.listboxNode.clientHeight
) {
var scrollBottom =
this.listboxNode.clientHeight + this.listboxNode.scrollTop;
var elementBottom = selectedOption.offsetTop + selectedOption.offsetHeight;
if (elementBottom > scrollBottom) {
this.listboxNode.scrollTop =
elementBottom - this.listboxNode.clientHeight;
} else if (selectedOption.offsetTop < this.listboxNode.scrollTop) {
this.listboxNode.scrollTop = selectedOption.offsetTop;
}
}
};
/**
* @description
* Enable/disable the up/down arrows based on the activeDescendant.
*/
aria.Listbox.prototype.checkUpDownButtons = function () {
var activeElement = document.getElementById(this.activeDescendant);
if (!this.moveUpDownEnabled) {
return;
}
if (!activeElement) {
this.upButton.setAttribute('aria-disabled', 'true');
this.downButton.setAttribute('aria-disabled', 'true');
return;
}
if (this.upButton) {
if (activeElement.previousElementSibling) {
this.upButton.setAttribute('aria-disabled', false);
} else {
this.upButton.setAttribute('aria-disabled', 'true');
}
}
if (this.downButton) {
if (activeElement.nextElementSibling) {
this.downButton.setAttribute('aria-disabled', false);
} else {
this.downButton.setAttribute('aria-disabled', 'true');
}
}
};
/**
* @description
* Add the specified items to the listbox. Assumes items are valid options.
* @param items
* An array of items to add to the listbox
*/
aria.Listbox.prototype.addItems = function (items) {
if (!items || !items.length) {
return;
}
items.forEach(
function (item) {
this.defocusItem(item);
this.toggleSelectItem(item);
this.listboxNode.append(item);
}.bind(this)
);
if (!this.activeDescendant) {
this.focusItem(items[0]);
}
this.handleItemChange('added', items);
};
/**
* @description
* Remove all of the selected items from the listbox; Removes the focused items
* in a single select listbox and the items with aria-selected in a multi
* select listbox.
* @returns {Array}
* An array of items that were removed from the listbox
*/
aria.Listbox.prototype.deleteItems = function () {
var itemsToDelete;
if (this.multiselectable) {
itemsToDelete = this.listboxNode.querySelectorAll('[aria-selected="true"]');
} else if (this.activeDescendant) {
itemsToDelete = [document.getElementById(this.activeDescendant)];
}
if (!itemsToDelete || !itemsToDelete.length) {
return [];
}
itemsToDelete.forEach(
function (item) {
item.remove();
if (item.id === this.activeDescendant) {
this.clearActiveDescendant();
}
}.bind(this)
);
this.handleItemChange('removed', itemsToDelete);
return itemsToDelete;
};
aria.Listbox.prototype.clearActiveDescendant = function () {
this.activeDescendant = null;
this.listboxNode.setAttribute('aria-activedescendant', null);
this.updateMoveButton();
this.checkUpDownButtons();
};
/**
* @description
* Shifts the currently focused item up on the list. No shifting occurs if the
* item is already at the top of the list.
*/
aria.Listbox.prototype.moveUpItems = function () {
if (!this.activeDescendant) {
return;
}
var currentItem = document.getElementById(this.activeDescendant);
var previousItem = currentItem.previousElementSibling;
if (previousItem) {
this.listboxNode.insertBefore(currentItem, previousItem);
this.handleItemChange('moved_up', [currentItem]);
}
this.checkUpDownButtons();
};
/**
* @description
* Shifts the currently focused item down on the list. No shifting occurs if
* the item is already at the end of the list.
*/
aria.Listbox.prototype.moveDownItems = function () {
if (!this.activeDescendant) {
return;
}
var currentItem = document.getElementById(this.activeDescendant);
var nextItem = currentItem.nextElementSibling;
if (nextItem) {
this.listboxNode.insertBefore(nextItem, currentItem);
this.handleItemChange('moved_down', [currentItem]);
}
this.checkUpDownButtons();
};
/**
* @description
* Delete the currently selected items and add them to the sibling list.
*/
aria.Listbox.prototype.moveItems = function () {
if (!this.siblingList) {
return;
}
var itemsToMove = this.deleteItems();
this.siblingList.addItems(itemsToMove);
};
/**
* @description
* Enable Up/Down controls to shift items up and down.
* @param upButton
* Up button to trigger up shift
* @param downButton
* Down button to trigger down shift
*/
aria.Listbox.prototype.enableMoveUpDown = function (upButton, downButton) {
this.moveUpDownEnabled = true;
this.upButton = upButton;
this.downButton = downButton;
upButton.addEventListener('click', this.moveUpItems.bind(this));
downButton.addEventListener('click', this.moveDownItems.bind(this));
};
/**
* @description
* Enable Move controls. Moving removes selected items from the current
* list and adds them to the sibling list.
* @param button
* Move button to trigger delete
* @param siblingList
* Listbox to move items to
*/
aria.Listbox.prototype.setupMove = function (button, siblingList) {
this.siblingList = siblingList;
this.moveButton = button;
button.addEventListener('click', this.moveItems.bind(this));
};
aria.Listbox.prototype.setHandleItemChange = function (handlerFn) {
this.handleItemChange = handlerFn;
};
aria.Listbox.prototype.setHandleFocusChange = function (focusChangeHandler) {
this.handleFocusChange = focusChangeHandler;
};
/*
* This content is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
*/
/* global aria */
'use strict';
/**
* ARIA Scrollable Listbox Example
*
* @function onload
* @description Initialize the listbox example once the page has loaded
*/
window.addEventListener('load', function () {
new aria.Listbox(document.getElementById('ss_elem_list'));
});
'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);
});
};
/*
* This content is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
*/
'use strict';
// Save a list of named combobox actions, for future readability
const SelectActions = {
Close: 0,
CloseSelect: 1,
First: 2,
Last: 3,
Next: 4,
Open: 5,
PageDown: 6,
PageUp: 7,
Previous: 8,
Select: 9,
Type: 10,
};
/*
* Helper functions
*/
// filter an array of options against an input string
// returns an array of options that begin with the filter string, case-independent
function filterOptions(options = [], filter, exclude = []) {
return options.filter((option) => {
const matches = option.toLowerCase().indexOf(filter.toLowerCase()) === 0;
return matches && exclude.indexOf(option) < 0;
});
}
// map a key press to an action
function getActionFromKey(event, menuOpen) {
const { key, altKey, ctrlKey, metaKey } = event;
const openKeys = ['ArrowDown', 'ArrowUp', 'Enter', ' ']; // all keys that will do the default open action
// handle opening when closed
if (!menuOpen && openKeys.includes(key)) {
return SelectActions.Open;
}
// home and end move the selected option when open or closed
if (key === 'Home') {
return SelectActions.First;
}
if (key === 'End') {
return SelectActions.Last;
}
// handle typing characters when open or closed
if (
key === 'Backspace' ||
key === 'Clear' ||
(key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey)
) {
return SelectActions.Type;
}
// handle keys when open
if (menuOpen) {
if (key === 'ArrowUp' && altKey) {
return SelectActions.CloseSelect;
} else if (key === 'ArrowDown' && !altKey) {
return SelectActions.Next;
} else if (key === 'ArrowUp') {
return SelectActions.Previous;
} else if (key === 'PageUp') {
return SelectActions.PageUp;
} else if (key === 'PageDown') {
return SelectActions.PageDown;
} else if (key === 'Escape') {
return SelectActions.Close;
} else if (key === 'Enter' || key === ' ') {
return SelectActions.CloseSelect;
}
}
}
// return the index of an option from an array of options, based on a search string
// if the filter is multiple iterations of the same letter (e.g "aaa"), then cycle through first-letter matches
function getIndexByLetter(options, filter, startIndex = 0) {
const orderedOptions = [
...options.slice(startIndex),
...options.slice(0, startIndex),
];
const firstMatch = filterOptions(orderedOptions, filter)[0];
const allSameLetter = (array) => array.every((letter) => letter === array[0]);
// first check if there is an exact match for the typed string
if (firstMatch) {
return options.indexOf(firstMatch);
}
// if the same letter is being repeated, cycle through first-letter matches
else if (allSameLetter(filter.split(''))) {
const matches = filterOptions(orderedOptions, filter[0]);
return options.indexOf(matches[0]);
}
// if no matches, return -1
else {
return -1;
}
}
// get an updated option index after performing an action
function getUpdatedIndex(currentIndex, maxIndex, action) {
const pageSize = 10; // used for pageup/pagedown
switch (action) {
case SelectActions.First:
return 0;
case SelectActions.Last:
return maxIndex;
case SelectActions.Previous:
return Math.max(0, currentIndex - 1);
case SelectActions.Next:
return Math.min(maxIndex, currentIndex + 1);
case SelectActions.PageUp:
return Math.max(0, currentIndex - pageSize);
case SelectActions.PageDown:
return Math.min(maxIndex, currentIndex + pageSize);
default:
return currentIndex;
}
}
// check if element is visible in browser view port
function isElementInView(element) {
var bounding = element.getBoundingClientRect();
return (
bounding.top >= 0 &&
bounding.left >= 0 &&
bounding.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
bounding.right <=
(window.innerWidth || document.documentElement.clientWidth)
);
}
// check if an element is currently scrollable
function isScrollable(element) {
return element && element.clientHeight < element.scrollHeight;
}
// ensure a given child element is within the parent's visible scroll area
// if the child is not visible, scroll the parent
function maintainScrollVisibility(activeElement, scrollParent) {
const { offsetHeight, offsetTop } = activeElement;
const { offsetHeight: parentOffsetHeight, scrollTop } = scrollParent;
const isAbove = offsetTop < scrollTop;
const isBelow = offsetTop + offsetHeight > scrollTop + parentOffsetHeight;
if (isAbove) {
scrollParent.scrollTo(0, offsetTop);
} else if (isBelow) {
scrollParent.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight);
}
}
/*
* Select Component
* Accepts a combobox element and an array of string options
*/
const Select = function (el, options = []) {
// element refs
this.el = el;
this.comboEl = el.querySelector('[role=combobox]');
this.listboxEl = el.querySelector('[role=listbox]');
// data
this.idBase = this.comboEl.id || 'combo';
this.options = options;
// state
this.activeIndex = 0;
this.open = false;
this.searchString = '';
this.searchTimeout = null;
// init
if (el && this.comboEl && this.listboxEl) {
this.init();
}
};
Select.prototype.init = function () {
// select first option by default
this.comboEl.innerHTML = this.options[0];
// add event listeners
this.comboEl.addEventListener('blur', this.onComboBlur.bind(this));
this.comboEl.addEventListener('click', this.onComboClick.bind(this));
this.comboEl.addEventListener('keydown', this.onComboKeyDown.bind(this));
// create options
this.options.map((option, index) => {
const optionEl = this.createOption(option, index);
this.listboxEl.appendChild(optionEl);
});
};
Select.prototype.createOption = function (optionText, index) {
const optionEl = document.createElement('div');
optionEl.setAttribute('role', 'option');
optionEl.id = `${this.idBase}-${index}`;
optionEl.className =
index === 0 ? 'combo-option option-current' : 'combo-option';
optionEl.setAttribute('aria-selected', `${index === 0}`);
optionEl.innerText = optionText;
optionEl.addEventListener('click', (event) => {
event.stopPropagation();
this.onOptionClick(index);
});
optionEl.addEventListener('mousedown', this.onOptionMouseDown.bind(this));
return optionEl;
};
Select.prototype.getSearchString = function (char) {
// reset typing timeout and start new timeout
// this allows us to make multiple-letter matches, like a native select
if (typeof this.searchTimeout === 'number') {
window.clearTimeout(this.searchTimeout);
}
this.searchTimeout = window.setTimeout(() => {
this.searchString = '';
}, 500);
// add most recent letter to saved search string
this.searchString += char;
return this.searchString;
};
Select.prototype.onComboBlur = function () {
// do not do blur action if ignoreBlur flag has been set
if (this.ignoreBlur) {
this.ignoreBlur = false;
return;
}
// select current option and close
if (this.open) {
this.selectOption(this.activeIndex);
this.updateMenuState(false, false);
}
};
Select.prototype.onComboClick = function () {
this.updateMenuState(!this.open, false);
};
Select.prototype.onComboKeyDown = function (event) {
const { key } = event;
const max = this.options.length - 1;
const action = getActionFromKey(event, this.open);
switch (action) {
case SelectActions.Last:
case SelectActions.First:
this.updateMenuState(true);
// intentional fallthrough
case SelectActions.Next:
case SelectActions.Previous:
case SelectActions.PageUp:
case SelectActions.PageDown:
event.preventDefault();
return this.onOptionChange(
getUpdatedIndex(this.activeIndex, max, action)
);
case SelectActions.CloseSelect:
event.preventDefault();
this.selectOption(this.activeIndex);
// intentional fallthrough
case SelectActions.Close:
event.preventDefault();
return this.updateMenuState(false);
case SelectActions.Type:
return this.onComboType(key);
case SelectActions.Open:
event.preventDefault();
return this.updateMenuState(true);
}
};
Select.prototype.onComboType = function (letter) {
// open the listbox if it is closed
this.updateMenuState(true);
// find the index of the first matching option
const searchString = this.getSearchString(letter);
const searchIndex = getIndexByLetter(
this.options,
searchString,
this.activeIndex + 1
);
// if a match was found, go to it
if (searchIndex >= 0) {
this.onOptionChange(searchIndex);
}
// if no matches, clear the timeout and search string
else {
window.clearTimeout(this.searchTimeout);
this.searchString = '';
}
};
Select.prototype.onOptionChange = function (index) {
// update state
this.activeIndex = index;
// update aria-activedescendant
this.comboEl.setAttribute('aria-activedescendant', `${this.idBase}-${index}`);
// update active option styles
const options = this.el.querySelectorAll('[role=option]');
[...options].forEach((optionEl) => {
optionEl.classList.remove('option-current');
});
options[index].classList.add('option-current');
// ensure the new option is in view
if (isScrollable(this.listboxEl)) {
maintainScrollVisibility(options[index], this.listboxEl);
}
// ensure the new option is visible on screen
// ensure the new option is in view
if (!isElementInView(options[index])) {
options[index].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
};
Select.prototype.onOptionClick = function (index) {
this.onOptionChange(index);
this.selectOption(index);
this.updateMenuState(false);
};
Select.prototype.onOptionMouseDown = function () {
// Clicking an option will cause a blur event,
// but we don't want to perform the default keyboard blur action
this.ignoreBlur = true;
};
Select.prototype.selectOption = function (index) {
// update state
this.activeIndex = index;
// update displayed value
const selected = this.options[index];
this.comboEl.innerHTML = selected;
// update aria-selected
const options = this.el.querySelectorAll('[role=option]');
[...options].forEach((optionEl) => {
optionEl.setAttribute('aria-selected', 'false');
});
options[index].setAttribute('aria-selected', 'true');
};
Select.prototype.updateMenuState = function (open, callFocus = true) {
if (this.open === open) {
return;
}
// update state
this.open = open;
// update aria-expanded and styles
this.comboEl.setAttribute('aria-expanded', `${open}`);
open ? this.el.classList.add('open') : this.el.classList.remove('open');
// update activedescendant
const activeID = open ? `${this.idBase}-${this.activeIndex}` : '';
this.comboEl.setAttribute('aria-activedescendant', activeID);
if (activeID === '' && !isElementInView(this.comboEl)) {
this.comboEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// move focus back to the combobox, if needed
callFocus && this.comboEl.focus();
};
// init select
window.addEventListener('load', function () {
const options = [
'Choose a Fruit',
'Apple',
'Banana',
'Blueberry',
'Boysenberry',
'Cherry',
'Cranberry',
'Durian',
'Eggplant',
'Fig',
'Grape',
'Guava',
'Huckleberry',
];
const selectEls = document.querySelectorAll('.js-select');
selectEls.forEach((el) => {
new Select(el, options);
});
});
Also see: Tab Triggers