Skip to content

Commit d55edc3

Browse files
committed
Merge 'add combo boxes & list boxes'
The changeset had to be updated for em-sizes instead of pixels. Link: jdan#216
2 parents c1d5cfd + 75ea394 commit d55edc3

4 files changed

Lines changed: 551 additions & 27 deletions

File tree

docs/docs.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,18 @@ button.active {
116116
inset -2px -2px #dfdfdf, inset 2px 2px #808080;
117117
}
118118

119+
.combo-box.key-nav > input[aria-haspopup] ~ ul > li[aria-selected=true] {
120+
color: #fff;
121+
background-color: #000080;
122+
}
123+
124+
div.key-nav > ul[role=listbox] > li[aria-current=true],
125+
div.key-nav > ul[role=listbox] > li:hover:not([aria-selected=true]),
126+
div:not(.key-nav) > input[aria-haspopup] ~ ul:has([aria-current=true]) > li[aria-selected=true]:not([aria-current=true]) {
127+
color: inherit;
128+
background-color: inherit;
129+
}
130+
119131
@media (max-width: 480px) {
120132
aside {
121133
display: none;

docs/index.html.ejs

Lines changed: 194 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
<li><a href="#group-box">GroupBox</a></li>
3232
<li><a href="#text-box">TextBox</a></li>
3333
<li><a href="#slider">Slider</a></li>
34-
<li><a href="#dropdown">Dropdown</a></li>
34+
<li><a href="#combo-box">ComboBox</a></li>
35+
<li><a href="#list-box">ListBox</a></li>
3536
<li>
3637
<a href="#window">Window</a>
3738
<ul>
@@ -467,48 +468,219 @@
467468
</section>
468469

469470
<section class="component">
470-
<h3 id="dropdown">Dropdown</h3>
471+
<h3 id="combo-box">ComboBox</h3>
471472
<div>
472473
<blockquote>
473-
A <em>drop-down list box</em> allows the selection of only a
474-
single item from a list. In its closed state, the control displays
475-
the current value for the control. The user opens the list to change
476-
the value.
474+
A <em>combo box</em> combines a text box with a list box. This allows the user to type an entry or choose one from the list.
477475

478476
<footer>
479-
&mdash; Microsoft Windows User Experience p. 175
477+
&mdash; Microsoft Windows User Experience p. 183
478+
</footer>
479+
</blockquote>
480+
481+
<p>
482+
There are 2 ways you can render a combo box. The first is using a text <code>input</code>, a parent <code>ul</code>, and children <code>li</code> together, wrapped inside a container element with the <code>combo-box</code> class. For accessibility, follow the minimum requirements below:
483+
</p>
484+
485+
<ul>
486+
<li>Add a <code>role="combobox"</code> attribute to the text <code>input</code></li>
487+
<li>Add a <code>role="listbox"</code> attribute to the <code>ul</code></li>
488+
<li>Add a <code>role="option"</code> attribute to each <code>li</code></li>
489+
<li>
490+
Specify the relationship between the list box and the text box by combining the <code>id</code> of the <code>listbox</code> with the <code>aria-controls</code> attribute on the text <code>input</code>
491+
</li>
492+
</ul>
493+
494+
<%- example(`
495+
<div class="combo-box" style="width: fit-content">
496+
<input type="text" id="example${getNewId()}-input" role="combobox" value="Regular" aria-controls="example${getCurrentId()}-listbox" />
497+
<ul role="listbox" id="example${getCurrentId()}-listbox" tabindex="-1">
498+
<li role="option" aria-selected="true">Regular</li>
499+
<li role="option">Italic</li>
500+
<li role="option">Bold</li>
501+
<li role="option">Bold Italic</li>
502+
</ul>
503+
</div>
504+
`) %>
505+
506+
<p>
507+
The second adds a <code>button</code> to toggle the visibility of the drop-down. For accessibility, follow these additional requirements:
508+
</p>
509+
510+
<ul>
511+
<li>Add <code>aria-haspopup="listbox"</code> and <code>aria-expanded</code> attributes to the text <code>input</code></li>
512+
</ul>
513+
514+
<%- example(`
515+
<div class="combo-box" style="width: fit-content">
516+
<input type="text" id="example${getNewId()}-input" role="combobox" value="h:mm:ss tt" aria-haspopup="listbox" aria-expanded="false" aria-controls="example${getCurrentId()}-listbox" />
517+
<button type="button" tabindex="-1"></button>
518+
<ul role="listbox" id="example${getCurrentId()}-listbox" tabindex="-1">
519+
<li role="option" aria-selected="true">h:mm:ss tt</li>
520+
<li role="option">hh:mm:ss tt</li>
521+
<li role="option">H:mm:ss</li>
522+
<li role="option">HH:mm:ss</li>
523+
</ul>
524+
</div>
525+
`) %>
526+
527+
<p>
528+
For more options of the list box, see the <a href="#list-box">ListBox</a> section.
529+
</p>
530+
</div>
531+
</section>
532+
533+
<section class="component">
534+
<h3 id="list-box">ListBox</h3>
535+
<div>
536+
<blockquote>
537+
A <em>list box</em> is a control for displaying a list of choices for the user.
538+
539+
<footer>
540+
&mdash; Microsoft Windows User Experience p. 170
480541
</footer>
481542
</blockquote>
482543

483544
<p>
484-
Dropdowns can be rendered by using the <code>select</code> and <code>option</code>
485-
elements.
545+
The simplest way to render a list box is by using the <code>select</code> element with a <code>multiple</code> attribute specified.
486546
</p>
487547

488548
<%- example(`
489-
<select>
549+
<select multiple>
490550
<option>5 - Incredible!</option>
491-
<option>4 - Great!</option>
551+
<option selected>4 - Great!</option>
492552
<option>3 - Pretty good</option>
493553
<option>2 - Not so great</option>
494554
<option>1 - Unfortunate</option>
495555
</select>
496556
`) %>
497557

498558
<p>
499-
By default, the first option will be selected. You can change this by
500-
giving one of your <code>option</code> elements the <code>selected</code>
501-
attribute.
559+
The complex way is using a combination of the <code>ul</code>/<code>li</code> elements with the role attributes.
502560
</p>
503561

504562
<%- example(`
505-
<select>
506-
<option>5 - Incredible!</option>
507-
<option>4 - Great!</option>
508-
<option selected>3 - Pretty good</option>
509-
<option>2 - Not so great</option>
510-
<option>1 - Unfortunate</option>
511-
</select>
563+
<ul role="listbox" tabindex="0" style="width: fit-content">
564+
<li role="option" aria-selected="true">Bitmap Image</li>
565+
<li role="option">Image Document</li>
566+
<li role="option">Media Clip</li>
567+
<li role="option">Wave Sound</li>
568+
<li role="option">WordPad Document</li>
569+
</ul>
570+
`) %>
571+
572+
<p>
573+
To remove the scroll bar of the list box, use the <code>no-scroll</code> class.
574+
</p>
575+
576+
<%- example(`
577+
<ul role="listbox" class="no-scroll" tabindex="0" style="width: 150px">
578+
<li role="option">Edit</li>
579+
<li role="option">Open</li>
580+
<li role="option">Print</li>
581+
</ul>
582+
`) %>
583+
584+
<blockquote>
585+
A <em>drop-down list box</em> allows the selection of only a single item from a list; the difference is that the list is displayed on demand. In its closed state, the control displays the current value for the control.
586+
587+
<footer>
588+
&mdash; Microsoft Windows User Experience p. 175
589+
</footer>
590+
</blockquote>
591+
592+
<p>
593+
A drop-down can be rendered in 2 ways. The first is using the native <code>select</code> and <code>option</code>. The second is using a text <code>input</code>, a <code>button</code>, a parent <code>ul</code>, and children <code>li</code> together, wrapped inside a container element with the <code>list-box</code> class. For accessibility, follow the minimum requirements below:
594+
</p>
595+
596+
<ul>
597+
<li>Add <code>aria-haspopup="listbox"</code> and <code>aria-expanded</code> attributes to the text <code>input</code></li>
598+
<li>
599+
Specify the relationship between the list box and the text box by combining the <code>id</code> of the <code>listbox</code> with the <code>aria-controls</code> attribute on the text <code>input</code>
600+
</li>
601+
</ul>
602+
603+
<%- example(`
604+
<div class="list-box" style="width: fit-content">
605+
<input type="text" id="example${getNewId()}-input" aria-haspopup="listbox" value="True Color (24 bit)" aria-expanded="false" aria-controls="example${getCurrentId()}-listbox" readonly />
606+
<button type="button" tabindex="-1"></button>
607+
<ul id="example${getCurrentId()}-listbox" role="listbox" tabindex="-1">
608+
<li role="option">Monochrome</li>
609+
<li role="option">16 Color</li>
610+
<li role="option">256 Color</li>
611+
<li role="option">High Color (16 bit)</li>
612+
<li role="option" aria-selected="true">True Color (24 bit)</li>
613+
</ul>
614+
</div>
615+
`) %>
616+
617+
<blockquote>
618+
Although most list boxes are single-selection lists, some contexts require the user to choose more than one item. <em>Multiple-selection list boxes</em> support this functionality.
619+
620+
<footer>
621+
&mdash; Microsoft Windows User Experience p. 176
622+
</footer>
623+
</blockquote>
624+
625+
<p>A multiple-selection list box can be rendered by adding the <code>aria-multiselectable</code> attribute to the <code>listbox</code>, along with checkboxes.</p>
626+
627+
<%- example(`
628+
<ul role="listbox" tabindex="0" aria-multiselectable="true" style="width: fit-content">
629+
<li role="option">
630+
<input type="checkbox" id="example${getNewId()}-checkbox-1" tabindex="-1">
631+
<label for="example${getCurrentId()}-checkbox-1">Yearly Statistic</label>
632+
</li>
633+
<li role="option">
634+
<input type="checkbox" id="example${getCurrentId()}-checkbox-2" tabindex="-1">
635+
<label for="example${getCurrentId()}-checkbox-2">Financial Summary</label>
636+
</li>
637+
<li role="option">
638+
<input type="checkbox" id="example${getCurrentId()}-checkbox-3" tabindex="-1">
639+
<label for="example${getCurrentId()}-checkbox-3">Samson Account</label>
640+
</li>
641+
<li role="option">
642+
<input type="checkbox" id="example${getCurrentId()}-checkbox-4" tabindex="-1">
643+
<label for="example${getCurrentId()}-checkbox-4">Lukison Review</label>
644+
</li>
645+
</ul>
646+
`) %>
647+
648+
<p>Group options by wrapping them in a <code>ul</code> with parent <code>li</code> with the <code>role="group"</code> attribute.
649+
To label a group of options, use a child <code>span</code> inside the parent <code>li</code> and reference its id in the <code>aria-labelledby</code> attribute of the <code>li</code>.</p>
650+
651+
<%- example(`
652+
<ul role="listbox" tabindex="0" aria-multiselectable="true" style="width: fit-content">
653+
<li role="group" aria-labelledby="example${getNewId()}-group-1">
654+
<span id="example${getCurrentId()}-group-1">Browsing</span>
655+
<ul>
656+
<li role="option">
657+
<input type="checkbox" id="example${getCurrentId()}-checkbox-1" tabindex="-1">
658+
<label for="example${getCurrentId()}-checkbox-1">Enable page transitions</label>
659+
</li>
660+
<li role="option">
661+
<input type="checkbox" id="example${getCurrentId()}-checkbox-2" tabindex="-1">
662+
<label for="example${getCurrentId()}-checkbox-2">Use smooth scrolling</label>
663+
</li>
664+
</ul>
665+
</li>
666+
<li role="group" aria-labelledby="example${getCurrentId()}-group-2">
667+
<span id="example${getCurrentId()}-group-2">Multimedia</span>
668+
<ul>
669+
<li role="option">
670+
<input type="checkbox" id="example${getCurrentId()}-checkbox-3" tabindex="-1">
671+
<label for="example${getCurrentId()}-checkbox-3">Play animations</label>
672+
</li>
673+
<li role="option">
674+
<input type="checkbox" id="example${getCurrentId()}-checkbox-4" tabindex="-1">
675+
<label for="example${getCurrentId()}-checkbox-4">Play sounds</label>
676+
</li>
677+
<li role="option">
678+
<input type="checkbox" id="example${getCurrentId()}-checkbox-5" tabindex="-1">
679+
<label for="example${getCurrentId()}-checkbox-5">Show pictures</label>
680+
</li>
681+
</ul>
682+
</li>
683+
</ul>
512684
`) %>
513685
</div>
514686
</section>
@@ -1126,4 +1298,5 @@
11261298
</p>
11271299
</main>
11281300
</body>
1301+
<script src="script.js"></script>
11291302
</html>

docs/script.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// List Boxes & Combo Boxes
2+
document.querySelectorAll('ul[role=listbox]').forEach(listbox => {
3+
const input = document.getElementById(listbox.id.replace('-listbox', '-input'));
4+
const expands = input?.hasAttribute('aria-haspopup');
5+
const multiselect = listbox.hasAttribute('aria-multiselectable');
6+
const options = Array.from(listbox.querySelectorAll('li[role="option"], li[role="group"] span'));
7+
let currentIndex = findIndex(), current = null;
8+
9+
function findIndex() {
10+
let index = options.findIndex(option => option.getAttribute('aria-current') === "true");
11+
if (index === -1 && !multiselect) {
12+
index = options.findIndex(option => option.getAttribute('aria-selected') === "true");
13+
} return index;
14+
}
15+
16+
function scroll() {
17+
const index = currentIndex === -1 ? 0 : currentIndex;
18+
options[index].scrollIntoView({ block: "nearest", behavior: "instant" });
19+
}
20+
21+
function removeCurrent() {
22+
current?.setAttribute('aria-current', 'false'), current = null;
23+
}
24+
25+
function updateCurrent(value = null) {
26+
if (input) { // Input-based listbox: update input value and aria-selected
27+
if (value !== null) input.value = value;
28+
options.forEach(option => option.setAttribute(
29+
'aria-selected', option.textContent.trim() === input.value.trim() ? 'true' : 'false'
30+
));
31+
} else { // Non-input-based listbox: use current for aria-current
32+
current = options[currentIndex];
33+
!multiselect ?
34+
options.forEach(option => option.setAttribute('aria-selected', option === current ? 'true' : 'false')) :
35+
(options.forEach(option => option.setAttribute('aria-current', 'false')), current.setAttribute('aria-current', 'true'));
36+
} (currentIndex = findIndex()) !== -1 && scroll();
37+
}
38+
39+
function toggleDropdown() {
40+
const isOpen = input.getAttribute('aria-expanded') === "true";
41+
input.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
42+
input.focus();
43+
!isOpen ? (currentIndex = findIndex(), scroll()) : removeCurrent();
44+
}
45+
46+
function toggleCheck() {
47+
const checkbox = current?.querySelector('input[type="checkbox"]');
48+
checkbox && (checkbox.checked = !checkbox.checked, current.setAttribute('aria-selected', checkbox.checked ? 'true' : 'false'));
49+
}
50+
51+
function navigationHandler(event) {
52+
expands && (listbox.parentElement.classList.add('key-nav'), current && (currentIndex = options.indexOf(current), removeCurrent()));
53+
54+
if (event.altKey && (event.key === "ArrowDown" || event.key === "ArrowUp") && expands) {
55+
event.preventDefault();
56+
toggleDropdown();
57+
} else if ((event.key === "ArrowDown" && currentIndex < options.length - 1) || (event.key === "ArrowUp" && currentIndex > 0)) {
58+
event.preventDefault();
59+
currentIndex += event.key === "ArrowDown" ? 1 : -1;
60+
updateCurrent(options[currentIndex].textContent);
61+
} else if (event.key === "Enter" && current?.role !== "group") {
62+
event.preventDefault();
63+
multiselect ? toggleCheck() : input && input.getAttribute('aria-expanded') === "true" &&
64+
(current = options[currentIndex], updateCurrent(current?.textContent), listbox.parentElement.classList.remove('key-nav'), toggleDropdown());
65+
} else if ((event.key === "Home" || event.key === "End") && !listbox.parentElement.classList.contains('combo-box')) {
66+
event.preventDefault();
67+
currentIndex = event.key === "Home" ? 0 : options.length - 1;
68+
updateCurrent(options[currentIndex].textContent);
69+
}
70+
}
71+
72+
listbox.addEventListener('click', (event) => {
73+
if (!input) listbox.focus();
74+
75+
let target = event.target.tagName === "INPUT" || event.target.tagName === "LABEL"
76+
? event.target.closest('li[role="option"]') : event.target;
77+
78+
if (target.tagName === "LI" && target.getAttribute('role') === "option" || target.tagName === "SPAN") {
79+
currentIndex = Array.from(options).indexOf(target);
80+
current = options[currentIndex];
81+
updateCurrent(current.textContent);
82+
expands ? toggleDropdown() : (multiselect && toggleCheck());
83+
}
84+
});
85+
86+
if (!input) {
87+
listbox.addEventListener('keydown', navigationHandler);
88+
listbox.addEventListener('mousedown', removeCurrent);
89+
listbox.addEventListener('focus', () => currentIndex === -1 && (currentIndex = 0, updateCurrent()));
90+
} else {
91+
input.addEventListener('input', () => updateCurrent(input.value));
92+
input.addEventListener('keydown', navigationHandler);
93+
listbox.addEventListener('mousedown', (e) => (e.preventDefault(), input.focus()));
94+
95+
if (expands) {
96+
const button = listbox.parentElement.querySelector('button');
97+
98+
function mouseHandler(event) {
99+
if (event.target.tagName !== "LI") return;
100+
listbox.parentElement.classList.contains('key-nav')
101+
? listbox.parentElement.classList.remove('key-nav')
102+
: removeCurrent();
103+
current = event.target;
104+
current.setAttribute('aria-current', 'true');
105+
}
106+
107+
listbox.addEventListener('mousemove', mouseHandler);
108+
listbox.addEventListener('mouseover', mouseHandler);
109+
110+
button.addEventListener('click', toggleDropdown);
111+
input.addEventListener('blur', () => {
112+
if (input.getAttribute('aria-expanded') === "true") {
113+
setTimeout(() => {
114+
if (![input, button].includes(document.activeElement)) toggleDropdown();
115+
}, 0);
116+
}
117+
});
118+
}
119+
}
120+
});

0 commit comments

Comments
 (0)