Anleitung zur Erstellung benutzerdefinierter Formularelemente
In manchen Fällen scheinen die verfügbaren nativen HTML-Formularelemente nicht auszureichen. Wenn Sie beispielsweise erweiterte Formatierungen für einige Elemente wie das <select>
-Element verwenden müssen oder benutzerdefinierte Verhaltensweisen bereitstellen möchten, sollten Sie in Betracht ziehen, eigene Elemente zu erstellen.
In diesem Artikel besprechen wir, wie man ein benutzerdefiniertes Element erstellt. Dabei arbeiten wir mit einem Beispiel: dem Nachbau des <select>
-Elements. Wir werden auch besprechen, wann und ob es sinnvoll ist, eigene Elemente zu erstellen, und was zu beachten ist, wenn der Bau eines eigenen Elements erforderlich ist.
Hinweis: Wir konzentrieren uns auf den Bau des Elements, nicht darauf, wie der Code generisch und wiederverwendbar wird; dies würde nicht-trivialen JavaScript-Code und DOM-Manipulationen in einem unbekannten Kontext erfordern, was über den Umfang dieses Artikels hinausgeht.
Design, Struktur und Semantik
Bevor Sie ein benutzerdefiniertes Element erstellen, sollten Sie genau herausfinden, was Sie erreichen möchten. Dies spart Ihnen wertvolle Zeit. Insbesondere ist es wichtig, alle Zustände Ihres Elements klar zu definieren. Dazu ist es gut, mit einem bestehenden Element zu beginnen, dessen Zustände und Verhalten gut bekannt sind, damit Sie diese so weit wie möglich nachahmen können.
In unserem Beispiel werden wir das <select>
-Element nachbauen. Hier ist das gewünschte Ergebnis:
Dieses Screenshot zeigt die drei Hauptzustände unseres Elements: den Normalzustand (links); den aktiven Zustand (in der Mitte) und den offenen Zustand (rechts).
Was das Verhalten betrifft, so rekonstruieren wir ein nativen HTML-Element. Daher sollte es dasselbe Verhalten und dieselbe Semantik wie das native HTML-Element haben. Unser Element muss sowohl mit der Maus als auch mit der Tastatur nutzbar sein und für einen Bildschirmleser verständlich, genau wie jedes native Element. Lassen Sie uns definieren, wie das Element jeden Zustand erreicht:
Das Element befindet sich im Normalzustand, wenn:
- die Seite geladen wird.
- das Element aktiv war und der Benutzer irgendwo außerhalb klickt.
- das Element aktiv war und der Benutzer den Fokus mit der Tastatur auf ein anderes Element verschiebt (z.B. die Tab-Taste).
Das Element befindet sich im aktiven Zustand, wenn:
- der Benutzer darauf klickt oder es auf einem Touchscreen berührt.
- der Benutzer die Tabulatortaste drückt und es den Fokus erhält.
- das Element im offenen Zustand war und der Benutzer darauf klickt.
Das Element befindet sich im offenen Zustand, wenn:
- das Element in einem anderen als dem offenen Zustand ist und der Benutzer darauf klickt.
Sobald wir wissen, wie Zustandsänderungen erfolgen, ist es wichtig zu definieren, wie sich der Wert des Elements ändert:
Der Wert ändert sich, wenn:
- der Benutzer im offenen Zustand auf eine Option klickt.
- der Benutzer im aktiven Zustand die Aufwärts- oder Abwärtspfeiltasten drückt.
Der Wert ändert sich nicht, wenn:
- der Benutzer die Aufwärtspfeiltaste drückt, wenn die erste Option ausgewählt ist.
- der Benutzer die Abwärtspfeiltaste drückt, wenn die letzte Option ausgewählt ist.
Schließlich definieren wir, wie sich die Optionen des Elements verhalten sollen:
- Wenn das Element geöffnet wird, wird die ausgewählte Option hervorgehoben.
- Wenn die Maus über eine Option bewegt wird, wird die Option hervorgehoben und die zuvor hervorgehobene Option wird in ihren Normalzustand zurückversetzt.
Für unser Beispiel enden wir an dieser Stelle; wenn Sie jedoch aufmerksam sind, werden Sie bemerken, dass einige Verhaltensweisen fehlen. Zum Beispiel, was glauben Sie, passiert, wenn der Benutzer die Tabulatortaste drückt, während das Element im offenen Zustand ist? Die Antwort ist nichts. OK, das richtige Verhalten scheint offensichtlich, aber die Tatsache ist, dass es leicht zu übersehen ist, wenn es nicht in unseren Spezifikationen definiert ist. Dies gilt insbesondere in einem Teamumfeld, in dem die Personen, die das Verhalten des Elements entwerfen, andere sind als diejenigen, die es implementieren.
Ein weiteres interessantes Beispiel: Was passiert, wenn der Benutzer die Aufwärts- oder Abwärtspfeiltasten drückt, während das Element im offenen Zustand ist? Das ist etwas kniffliger. Wenn Sie davon ausgehen, dass der aktive und der offene Zustand vollständig unterschiedlich sind, lautet die Antwort erneut "nichts wird passieren", weil wir keine Tastaturinteraktionen für den offenen Zustand definiert haben. Wenn Sie jedoch davon ausgehen, dass sich der aktive und der offene Zustand teilweise überschneiden, kann sich der Wert ändern, aber die Option wird definitiv nicht entsprechend hervorgehoben, weil wir keine Tastaturinteraktionen über Optionen definiert haben, wenn sich das Element im offenen Zustand befindet (wir haben nur definiert, was passieren soll, wenn das Element geöffnet wird, aber nichts danach).
Wir müssen weiter denken: Was ist mit der Escape-Taste? Das Drücken der Esc-Taste schließt ein geöffnetes Auswahlfeld. Denken Sie daran, wenn Sie die gleiche Funktionalität wie das vorhandene native <select>
bereitstellen möchten, sollte es sich genauso verhalten wie das Auswahlfeld für alle Benutzer, von der Tastatur bis zur Maus, zum Touchscreen und dem Bildschirmleser sowie jedem anderen Eingabegerät.
In unserem Beispiel sind die fehlenden Spezifikationen offensichtlich, sodass wir uns darum kümmern werden, aber es kann ein echtes Problem für exotische, neue Steuerelemente sein. Bei standardisierten Elementen wie dem <select>
haben die Autoren der Spezifikation viel Zeit darauf verwendet, alle Interaktionen für jeden Anwendungsfall für jedes Eingabegerät zu spezifizieren. Neue Steuerelemente zu erstellen ist nicht einfach, besonders wenn Sie etwas erstellen, das noch nie zuvor gemacht wurde und daher niemand die geringste Vorstellung davon hat, was die erwarteten Verhaltensweisen und Interaktionen sind. Zumindest Auswahlfelder wurden schon einmal erstellt, sodass wir wissen, wie es sich verhalten soll!
Neue Interaktionen zu entwerfen, ist generell nur eine Option für sehr große Marktteilnehmer, die genug Reichweite haben, damit eine von ihnen geschaffene Interaktion zum Standard werden kann. Apple hat beispielsweise 2001 mit dem iPod das Scrollrad eingeführt. Sie hatten den Marktanteil, um eine völlig neue Art der Interaktion mit einem Gerät erfolgreich einzuführen, was den meisten Gerätefirmen nicht möglich ist.
Am besten erfindet man keine neuen Benutzerinteraktionen. Für jede von Ihnen hinzugefügte Interaktion ist es entscheidend, Zeit in der Entwurfsphase zu verbringen; wenn Sie ein Verhalten schlecht definieren oder eines vergessen, wird es sehr schwer sein, es umzudefinieren, sobald die Benutzer sich daran gewöhnt haben. Wenn Sie Zweifel haben, fragen Sie nach der Meinung anderer, und wenn Sie das Budget dafür haben, zögern Sie nicht Benutzertests durchzuführen. Dieser Prozess nennt sich UX-Design. Wenn Sie mehr über dieses Thema erfahren möchten, sollten Sie die folgenden hilfreichen Ressourcen überprüfen:
Hinweis:
Außerdem gibt es in den meisten Systemen eine Möglichkeit, das <select>
-Element mit der Tastatur zu öffnen, um alle verfügbaren Optionen anzusehen (das ist dasselbe wie ein Klick auf das <select>
-Element mit der Maus). Dies wird unter Windows mit Alt + Down erreicht. Wir haben dies in unserem Beispiel nicht implementiert, aber es wäre einfach zu tun, da der Mechanismus bereits für das click
Ereignis implementiert ist.
HTML-Struktur und (einige) Semantik definieren
Nachdem die grundlegende Funktionalität des Elements entschieden wurde, ist es Zeit, mit dem Aufbau zu beginnen. Der erste Schritt ist, die HTML-Struktur zu definieren und ihr einige grundlegende Semantik zu geben. Hier ist, was wir benötigen, um ein <select>
-Element nachzubauen:
<!-- This is our main container for our control.
The tabindex attribute is what allows the user to focus on the control.
We'll see later that it's better to set it through JavaScript. -->
<div class="select" tabindex="0">
<!-- This container will be used to display the current value of the control -->
<span class="value">Cherry</span>
<!-- This container will contain all the options available for our control.
Because it's a list, it makes sense to use the ul element. -->
<ul class="optList">
<!-- Each option only contains the value to be displayed, we'll see later
how to handle the real value that will be sent with the form data -->
<li class="option">Cherry</li>
<li class="option">Lemon</li>
<li class="option">Banana</li>
<li class="option">Strawberry</li>
<li class="option">Apple</li>
</ul>
</div>
Beachten Sie die Verwendung von Klassennamen; Diese identifizieren jeden relevanten Teil unabhängig von den tatsächlich verwendeten zugrunde liegenden HTML-Elementen. Dies ist wichtig, um sicherzustellen, dass wir unser CSS und JavaScript nicht an eine starke HTML-Struktur binden, sodass wir später Implementierungsänderungen vornehmen können, ohne Code zu brechen, der das Element verwendet. Beispielsweise, was passiert, wenn Sie später das Äquivalent des <optgroup>
-Elements implementieren möchten?
Klassennamen bieten jedoch keinen semantischen Wert. In diesem Zustand "sieht" ein Benutzer des Bildschirmlesers nur eine ungeordnete Liste. Wir werden etwas später ARIA-Semantik hinzufügen.
Das Aussehen und Gefühl mit CSS gestalten
Jetzt, da wir eine Struktur haben, können wir mit dem Design unseres Elements beginnen. Der ganze Punkt beim Erstellen dieses benutzerdefinierten Elements besteht darin, es genau so zu gestalten, wie wir es möchten. Dazu werden wir unsere CSS-Arbeit in zwei Teile aufteilen: Der erste Teil besteht aus den CSS-Regeln, die absolut notwendig sind, um unser Element wie ein <select>
-Element funktionieren zu lassen, und der zweite Teil besteht aus den schicken Stilen, die verwendet werden, um es so aussehen zu lassen, wie wir es möchten.
Erforderliche Stile
Die erforderlichen Stile sind diejenigen, die notwendig sind, um die drei Zustände unseres Elements zu handhaben.
.select {
/* This will create a positioning context for the list of options;
adding this to `.select:focus-within` will be a better option when fully supported
*/
position: relative;
/* This will make our control become part of the text flow and sizable at the same time */
display: inline-block;
}
Wir benötigen eine zusätzliche Klasse active
, um das Aussehen unseres Elements im aktiven Zustand zu definieren. Da unser Element fokussierbar ist, ergänzen wir diesen benutzerdefinierten Stil mit der :focus
Pseudo-Klasse, um sicherzustellen, dass sie sich gleich verhalten.
.select.active,
.select:focus {
outline-color: transparent;
/* This box-shadow property is not exactly required, however it's imperative to ensure
active state is visible, especially to keyboard users, that we use it as a default value. */
box-shadow: 0 0 3px 1px #227755;
}
Jetzt kümmern wir uns um die Liste der Optionen:
/* The .select selector here helps to make sure we only select
element inside our control. */
.select .optList {
/* This will make sure our list of options will be displayed below the value
and out of the HTML flow */
position: absolute;
top: 100%;
left: 0;
}
Wir benötigen eine zusätzliche Klasse, um zu handhaben, wann die Liste der Optionen ausgeblendet ist. Dies ist notwendig, um die Unterschiede zwischen dem aktiven Zustand und dem offenen Zustand zu verwalten, die nicht genau übereinstimmen.
.select .optList.hidden {
/* This is a simple way to hide the list in an accessible way;
we will talk more about accessibility in the end */
max-height: 0;
visibility: hidden;
}
Hinweis:
Wir könnten auch transform: scale(1, 0)
verwendet haben, um der Optionsliste keine Höhe und volle Breite zu geben.
Verschönerung
Jetzt, da wir die grundlegende Funktionalität bereitgestellt haben, kann der Spaß beginnen. Das folgende ist nur ein Beispiel für das, was möglich ist, und wird dem Screenshot am Anfang dieses Artikels entsprechen. Sie sollten jedoch experimentieren und sehen, was Sie sich einfallen lassen können.
.select {
/* The computations are made assuming 1em equals 16px which is the default value in most browsers.
If you are lost with px to em conversion, try https://nekocalc.com/px-to-em-converter */
font-size: 0.625em; /* this (10px) is the new font size context for em value in this context */
font-family: Verdana, Arial, sans-serif;
box-sizing: border-box;
/* We need extra room for the down arrow we will add */
padding: 0.1em 2.5em 0.2em 0.5em;
width: 10em; /* 100px */
border: 0.2em solid #000;
border-radius: 0.4em;
box-shadow: 0 0.1em 0.2em rgb(0 0 0 / 45%);
/* The first declaration is for browsers that do not support linear gradients. */
background: #f0f0f0;
background: linear-gradient(0deg, #e3e3e3, #fcfcfc 50%, #f0f0f0);
}
.select .value {
/* Because the value can be wider than our control, we have to make sure it will not
change the control's width. If the content overflows, we display an ellipsis */
display: inline-block;
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
vertical-align: top;
}
Wir benötigen kein zusätzliches Element, um den Abwärtspfeil zu gestalten; Stattdessen verwenden wir das ::after
Pseudo-Element. Es könnte auch mit einem einfachen Hintergrundbild auf der select
-Klasse implementiert werden.
.select::after {
content: "▼"; /* We use the unicode character U+25BC; make sure to set a charset meta tag */
position: absolute;
z-index: 1; /* This will be important to keep the arrow from overlapping the list of options */
top: 0;
right: 0;
box-sizing: border-box;
height: 100%;
width: 2em;
padding-top: 0.1em;
border-left: 0.2em solid #000;
border-radius: 0 0.1em 0.1em 0;
background-color: #000;
color: #fff;
text-align: center;
}
Als nächstes gestalten wir die Liste der Optionen:
.select .optList {
z-index: 2; /* We explicitly said the list of options will always be on top of the down arrow */
/* this will reset the default style of the ul element */
list-style: none;
margin: 0;
padding: 0;
box-sizing: border-box;
/* If the values are smaller than the control, the list of options
will be as wide as the control itself */
min-width: 100%;
/* In case the list is too long, its content will overflow vertically
(which will add a vertical scrollbar automatically) but never horizontally
(because we haven't set a width, the list will adjust its width automatically.
If it can't, the content will be truncated) */
max-height: 10em; /* 100px */
overflow-y: auto;
overflow-x: hidden;
border: 0.2em solid #000;
border-top-width: 0.1em;
border-radius: 0 0 0.4em 0.4em;
box-shadow: 0 0.2em 0.4em rgb(0 0 0 / 40%);
background: #f0f0f0;
}
Für die Optionen müssen wir eine highlight
-Klasse hinzufügen, um den Wert identifizieren zu können, den der Benutzer auswählt (oder ausgewählt hat).
.select .option {
padding: 0.2em 0.3em; /* 2px 3px */
}
.select .highlight {
background: #000;
color: #ffffff;
}
So sieht das Ergebnis mit unseren drei Zuständen aus (hier den Quellcode ansehen):
Grundzustand
Aktiver Zustand
Offener Zustand
Ihr Steuerelement mit JavaScript zum Leben erwecken
Nun, da unser Design und unsere Struktur bereit sind, können wir den JavaScript-Code schreiben, um das Element tatsächlich arbeiten zu lassen.
Warnung: Der folgende Code ist ein Lerncode, kein Produktionscode, und sollte nicht unverändert verwendet werden. Er ist weder zukunftssicher noch wird er in alten Browsern funktionieren. Er hat auch redundante Teile, die im Produktionscode optimiert werden sollten.
Warum funktioniert es nicht?
Bevor wir anfangen, ist es wichtig zu beachten, dass JavaScript im Browser eine unzuverlässige Technologie ist. Benutzerdefinierte Steuerelemente verlassen sich auf JavaScript, um alles zu verbinden. Jedoch gibt es Fälle, in denen JavaScript nicht im Browser ausgeführt werden kann:
- Der Benutzer hat JavaScript deaktiviert: Dies ist ungewöhnlich; sehr wenige Menschen deaktivieren heutzutage JavaScript.
- Das Skript wurde nicht geladen: Dies ist einer der häufigsten Fälle, insbesondere in der mobilen Welt, in der das Netzwerk nicht besonders zuverlässig ist.
- Das Skript ist fehlerhaft: Sie sollten immer diese Möglichkeit in Betracht ziehen.
- Das Skript ist mit einem Drittanbieter-Skript in Konflikt geraten: Das kann mit Tracking-Skripten oder Browser-Erweiterungen passieren, die der Benutzer verwendet.
- Das Skript ist mit einer Browsererweiterung in Konflikt oder wird von ihr beeinträchtigt (wie etwa der NoScript-Erweiterung in Firefox oder der ScriptBlock-Erweiterung in Chrome).
- Der Benutzer verwendet einen alten Browser, und eine der benötigten Funktionen wird nicht unterstützt: Das wird häufig passieren, wenn Sie moderne APIs verwenden.
- Der Benutzer interagiert mit dem Inhalt, bevor das JavaScript vollständig heruntergeladen, analysiert und ausgeführt wurde.
Aufgrund dieser Risiken ist es wirklich wichtig, ernsthaft zu überlegen, was passiert, wenn Ihr JavaScript nicht funktioniert. Wir werden Optionen betrachten und die Grundlagen in unserem Beispiel behandeln (eine vollständige Diskussion über die Lösung dieses Problems für alle Szenarien würde ein Buch erfordern). Denken Sie nur daran, dass es wichtig ist, Ihr Skript generisch und wiederverwendbar zu machen.
In unserem Beispiel werden wir, wenn unser JavaScript-Code nicht läuft, auf die Anzeige eines standardmäßigen <select>
-Elements zurückgreifen. Wir schließen unser Steuerelement und das <select>
-Element ein; welches angezeigt wird, hängt von der Klasse des Body-Elements ab, wobei die Klasse des Body-Elements von dem Skript, das das Steuerelement ausführt, erfolgreich aktualisiert wird.
Um dies zu erreichen, benötigen wir zwei Dinge:
Erstens müssen wir vor jeder Instanz unseres benutzerdefinierten Steuerelements ein reguläres <select>
-Element hinzufügen. Es gibt einen Vorteil, dieses "zusätzliche" Auswahlfeld zu haben, selbst wenn unser JavaScript wie erhofft funktioniert: wir werden dieses Auswahlfeld verwenden, um Daten von unserem benutzerdefinierten Steuerelement zusammen mit den restlichen Formulardaten zu senden. Dies werden wir später ausführlicher behandeln.
<body class="no-widget">
<form>
<select name="myFruit">
<option>Cherry</option>
<option>Lemon</option>
<option>Banana</option>
<option>Strawberry</option>
<option>Apple</option>
</select>
<div class="select">
<span class="value">Cherry</span>
<ul class="optList hidden">
<li class="option">Cherry</li>
<li class="option">Lemon</li>
<li class="option">Banana</li>
<li class="option">Strawberry</li>
<li class="option">Apple</li>
</ul>
</div>
</form>
</body>
Zweitens benötigen wir zwei neue Klassen, um uns das nicht benötigte Element zu verbergen: wir verstecken das benutzerdefinierte Steuerelement optisch, wenn unser Skript nicht läuft oder das "echte" <select>
-Element, wenn es läuft. Beachten Sie, dass unser HTML-Code standardmäßig unser benutzerdefiniertes Steuerelement ausblendet.
.widget select,
.no-widget .select {
/* This CSS selector basically says:
- either we have set the body class to "widget" and thus we hide the actual <select> element
- or we have not changed the body class, therefore the body class is still "no-widget",
so the elements whose class is "select" must be hidden */
position: absolute;
left: -5000em;
height: 0;
overflow: hidden;
}
Dieses CSS verbirgt eines der Elemente visuell, aber es ist weiterhin für Bildschirmleser verfügbar.
Nun benötigen wir einen JavaScript-Schalter, um festzulegen, ob das Skript läuft oder nicht. Dieser Schalter ist ein paar Zeilen: wenn unser Skript zur Ladezeit der Seite läuft, wird es die Klasse no-widget
entfernen und die Klasse widget
hinzufügen, wodurch die Sichtbarkeit des <select>
-Elements und des benutzerdefinierten Steuerelements ausgetauscht wird.
window.addEventListener("load", () => {
document.body.classList.remove("no-widget");
document.body.classList.add("widget");
});
Ohne JS
Den vollständigen Quellcode ansehen.
Mit JS
Den vollständigen Quellcode ansehen.
Hinweis:
Wenn Sie wirklich möchten, dass Ihr Code generisch und wiederverwendbar ist, ist es weit besser, anstatt eines Klassenschalters nur die Widget-Klasse hinzuzufügen, um die <select>
-Elemente zu verbergen, und den DOM-Baum dynamisch nach jedem <select>
-Element in der Seite hinzuzufügen, der das benutzerdefinierte Steuerelement darstellt.
Die Aufgabe erleichtern
In dem Code, den wir gleich erstellen werden, verwenden wir die Standard-JavaScript- und DOM-APIs, um alle notwendigen Arbeitsgänge durchzuführen. Die Funktionen, die wir verwenden möchten, sind folgende:
Ereignis-Callbacks erstellen
Die Grundlage ist getan. Jetzt können wir beginnen, alle Funktionen zu definieren, die jedes Mal verwendet werden, wenn der Benutzer mit unserem Steuerelement interagiert.
// This function will be used each time we want to deactivate a custom control
// It takes one parameter
// select : the DOM node with the `select` class to deactivate
function deactivateSelect(select) {
// If the control is not active there is nothing to do
if (!select.classList.contains("active")) return;
// We need to get the list of options for the custom control
const optList = select.querySelector(".optList");
// We close the list of option
optList.classList.add("hidden");
// and we deactivate the custom control itself
select.classList.remove("active");
}
// This function will be used each time the user wants to activate the control
// (which, in turn, will deactivate other select controls)
// It takes two parameters:
// select : the DOM node with the `select` class to activate
// selectList : the list of all the DOM nodes with the `select` class
function activeSelect(select, selectList) {
// If the control is already active there is nothing to do
if (select.classList.contains("active")) return;
// We have to turn off the active state on all custom controls
// Because the deactivateSelect function fulfills all the requirements of the
// forEach callback function, we use it directly without using an intermediate
// anonymous function.
selectList.forEach(deactivateSelect);
// And we turn on the active state for this specific control
select.classList.add("active");
}
// This function will be used each time the user wants to open/closed the list of options
// It takes one parameter:
// select : the DOM node with the list to toggle
function toggleOptList(select) {
// The list is kept from the control
const optList = select.querySelector(".optList");
// We change the class of the list to show/hide it
optList.classList.toggle("hidden");
}
// This function will be used each time we need to highlight an option
// It takes two parameters:
// select : the DOM node with the `select` class containing the option to highlight
// option : the DOM node with the `option` class to highlight
function highlightOption(select, option) {
// We get the list of all option available for our custom select element
const optionList = select.querySelectorAll(".option");
// We remove the highlight from all options
optionList.forEach((other) => {
other.classList.remove("highlight");
});
// We highlight the right option
option.classList.add("highlight");
}
Diese benötigen Sie, um die verschiedenen Zustände des benutzerdefinierten Steuerelements zu handhaben.
Als nächstes binden wir diese Funktionen an die entsprechenden Ereignisse:
// We handle the event binding when the document is loaded.
window.addEventListener("load", () => {
const selectList = document.querySelectorAll(".select");
// Each custom control needs to be initialized
selectList.forEach((select) => {
// as well as all its `option` elements
const optionList = select.querySelectorAll(".option");
// Each time a user hovers their mouse over an option, we highlight the given option
optionList.forEach((option) => {
option.addEventListener("mouseover", () => {
// Note: the `select` and `option` variable are closures
// available in the scope of our function call.
highlightOption(select, option);
});
});
// Each times the user clicks on or taps a custom select element
select.addEventListener("click", (event) => {
// Note: the `select` variable is a closure
// available in the scope of our function call.
// We toggle the visibility of the list of options
toggleOptList(select);
});
// In case the control gains focus
// The control gains the focus each time the user clicks on it or each time
// they use the tabulation key to access the control
select.addEventListener("focus", (event) => {
// Note: the `select` and `selectList` variable are closures
// available in the scope of our function call.
// We activate the control
activeSelect(select, selectList);
});
// In case the control loses focus
select.addEventListener("blur", (event) => {
// Note: the `select` variable is a closure
// available in the scope of our function call.
// We deactivate the control
deactivateSelect(select);
});
// Loose focus if the user hits `esc`
select.addEventListener("keyup", (event) => {
// deactivate on keyup of `esc`
if (event.key === "Escape") {
deactivateSelect(select);
}
});
});
});
An diesem Punkt wird unser Steuerelement entsprechend unserem Design den Zustand ändern, aber sein Wert wird noch nicht aktualisiert. Das kümmern wir uns als nächstes.
Live-Beispiel
Den vollständigen Quellcode ansehen.
Den Wert des Steuerelements handhaben
Nachdem unser Steuerelement funktioniert, müssen wir Code hinzufügen, um den Wert entsprechend der Benutzereingabe zu aktualisieren, und es ermöglichen, dass der Wert zusammen mit den Formulardaten gesendet wird.
Der einfachste Weg, dies zu tun, ist, unter der Haube ein natives Steuerelement zu verwenden. Ein solches Steuerelement wird den Wert mit allen eingebauten Steuerelementen liefern, die der Browser bereitstellt, und der Wert wird wie gewohnt gesendet, wenn ein Formular übermittelt wird. Es macht keinen Sinn, das Rad neu zu erfinden, wenn wir all dies für uns erledigen lassen können.
Wie bereits gesehen, verwenden wir bereits ein natives Auswahlfeld aus Gründen der Zugänglichkeit als Fallback; wir können dessen Wert mit dem unseres benutzerdefinierten Steuerelements synchronisieren:
// This function updates the displayed value and synchronizes it with the native control.
// It takes two parameters:
// select : the DOM node with the class `select` containing the value to update
// index : the index of the value to be selected
function updateValue(select, index) {
// We need to get the native control for the given custom control
// In our example, that native control is a sibling of the custom control
const nativeWidget = select.previousElementSibling;
// We also need to get the value placeholder of our custom control
const value = select.querySelector(".value");
// And we need the whole list of options
const optionList = select.querySelectorAll(".option");
// We set the selected index to the index of our choice
nativeWidget.selectedIndex = index;
// We update the value placeholder accordingly
value.textContent = optionList[index].textContent;
// And we highlight the corresponding option of our custom control
highlightOption(select, optionList[index]);
}
// This function returns the current selected index in the native control
// It takes one parameter:
// select : the DOM node with the class `select` related to the native control
function getIndex(select) {
// We need to access the native control for the given custom control
// In our example, that native control is a sibling of the custom control
const nativeWidget = select.previousElementSibling;
return nativeWidget.selectedIndex;
}
Mit diesen beiden Funktionen können wir die nativen Steuerelemente an die benutzerdefinierten binden:
// We handle event binding when the document is loaded.
window.addEventListener("load", () => {
const selectList = document.querySelectorAll(".select");
// Each custom control needs to be initialized
selectList.forEach((select) => {
const optionList = select.querySelectorAll(".option");
const selectedIndex = getIndex(select);
// We make our custom control focusable
select.tabIndex = 0;
// We make the native control no longer focusable
select.previousElementSibling.tabIndex = -1;
// We make sure that the default selected value is correctly displayed
updateValue(select, selectedIndex);
// Each time a user clicks on an option, we update the value accordingly
optionList.forEach((option, index) => {
option.addEventListener("click", (event) => {
updateValue(select, index);
});
});
// Each time a user uses their keyboard on a focused control, we update the value accordingly
select.addEventListener("keyup", (event) => {
let index = getIndex(select);
// When the user hits the Escape key, deactivate the custom control
if (event.key === "Escape") {
deactivateSelect(select);
}
// When the user hits the down arrow, we jump to the next option
if (event.key === "ArrowDown" && index < optionList.length - 1) {
index++;
// Prevent the default action of the ArrowDown key press.
// Without this, the page would scroll down when the ArrowDown key is pressed.
event.preventDefault();
}
// When the user hits the up arrow, we jump to the previous option
if (event.key === "ArrowUp" && index > 0) {
index--;
// Prevent the default action of the ArrowUp key press.
event.preventDefault();
}
if (event.key === "Enter" || event.key === " ") {
// If Enter or Space is pressed, toggle the option list
toggleOptList(select);
}
updateValue(select, index);
});
});
});
Im obigen Code ist die Verwendung der tabIndex
-Eigenschaft bemerkenswert. Diese Eigenschaft zu verwenden ist notwendig, um sicherzustellen, dass das native Steuerelement niemals den Fokus erhält und um sicherzustellen, dass unser benutzerdefiniertes Steuerelement den Fokus erhält, wenn der Benutzer seine Tastatur oder Maus verwendet.
Damit sind wir fertig!
Live-Beispiel
Den vollständigen Quellcode hier ansehen.
Aber Moment mal, sind wir wirklich fertig?
Es zugänglich machen
Wir haben etwas gebaut, das funktioniert, und obwohl wir noch weit von einem vollständig ausgestatteten Auswahlfeld entfernt sind, funktioniert es recht gut. Aber was wir getan haben, ist nichts weiter als das Basteln mit dem DOM. Es hat keine echten Semantiken, und obwohl es wie ein Auswahlfeld aussieht, ist es aus Sicht des Browsers keines, sodass unterstützende Technologien es nicht als Auswahlfeld verstehen können. Kurz gesagt, dieses schöne neue Auswahlfeld ist nicht zugänglich!
Zum Glück gibt es eine Lösung, die sich ARIA nennt. ARIA steht für "Accessible Rich Internet Application" und ist eine W3C-Spezifikation speziell für das, was wir hier tun: Webanwendungen und benutzerdefinierte Steuerelemente zugänglich zu machen. Es ist im Wesentlichen ein Satz von Attributen, die HTML erweitern, sodass wir besser beschreiben können, welche Rollen, Zustände und Eigenschaften das Element, das wir gerade entworfen haben, hat. Die Verwendung dieser Attribute kann durch Bearbeitung des HTML-Markups erfolgen. Wir aktualisieren auch die ARIA-Attribute über JavaScript, während der Benutzer seinen ausgewählten Wert aktualisiert.
Das role
-Attribut
Das Schlüsselattribut, das von ARIA verwendet wird, ist das role
-Attribut. Das role
-Attribut akzeptiert einen Wert, der definiert, wofür ein Element verwendet wird. Jede Rolle definiert ihre eigenen Anforderungen und Verhaltensweisen. In unserem Beispiel werden wir die listbox
-Rolle verwenden. Es ist eine "kompositorische Rolle", was bedeutet, dass Elemente mit dieser Rolle erwarten, Kinder zu haben, von denen jedes eine spezifische Rolle hat (in diesem Fall zumindest ein Kind mit der option
-Rolle).
Es ist auch erwähnenswert, dass ARIA Rollen definiert, die standardmäßig auf standardmäßiges HTML-Markup angewendet werden. Zum Beispiel entspricht das <table>
-Element der Rolle grid
, und das <ul>
-Element entspricht der Rolle list
. Da wir ein <ul>
-Element verwenden, möchten wir sicherstellen, dass die listbox
-Rolle unseres Steuerelements die list
-Rolle des <ul>
-Elements überschreiben wird. Zu diesem Zweck verwenden wir die Rolle presentation
. Diese Rolle ist dafür ausgelegt, anzuzeigen, dass ein Element keine besondere Bedeutung hat und ausschließlich zur Darstellung von Informationen verwendet wird. Wir werden sie auf unser <ul>
-Element anwenden.
Um die listbox
-Rolle zu unterstützen, müssen wir nur unser HTML wie folgt aktualisieren:
<!-- We add the role="listbox" attribute to our top element -->
<div class="select" role="listbox">
<span class="value">Cherry</span>
<!-- We also add the role="presentation" to the ul element -->
<ul class="optList" role="presentation">
<!-- And we add the role="option" attribute to all the li elements -->
<li role="option" class="option">Cherry</li>
<li role="option" class="option">Lemon</li>
<li role="option" class="option">Banana</li>
<li role="option" class="option">Strawberry</li>
<li role="option" class="option">Apple</li>
</ul>
</div>
Hinweis:
Es ist nicht erforderlich, sowohl das role
-Attribut als auch ein class
-Attribut einzuschließen. Verwenden Sie stattdessen .option
die [role="option"]
Attributselektoren in Ihrem CSS.
Das aria-selected
-Attribut
Die Verwendung des role
-Attributs ist nicht genug. ARIA stellt auch viele Zustands- und Eigenschaftsattribute bereit. Je mehr und besser Sie sie verwenden, desto besser wird Ihr Steuerelement von unterstützenden Technologien verstanden. In unserem Fall werden wir uns auf das aria-selected
-Attribut beschränken.
Das aria-selected
-Attribut wird verwendet, um zu markieren, welche Option momentan ausgewählt ist; dies hilft unterstützenden Technologien, dem Benutzer mitzuteilen, was die aktuelle Auswahl ist. Wir werden es dynamisch mit JavaScript verwenden, um die ausgewählte Option jedes Mal zu markieren, wenn der Benutzer eine auswählt. Zu diesem Zweck müssen wir unsere updateValue()
-Funktion überarbeiten:
function updateValue(select, index) {
const nativeWidget = select.previousElementSibling;
const value = select.querySelector(".value");
const optionList = select.querySelectorAll('[role="option"]');
// We make sure that all the options are not selected
optionList.forEach((other) => {
other.setAttribute("aria-selected", "false");
});
// We make sure the chosen option is selected
optionList[index].setAttribute("aria-selected", "true");
nativeWidget.selectedIndex = index;
value.textContent = optionList[index].textContent;
highlightOption(select, optionList[index]);
}
Es könnte einfacher erscheinen, dem Bildschirmleser zu gestatten, sich auf das außerhalb des Bildschirms befindliche Auswahlfeld zu konzentrieren und unser stilisiertes zu ignorieren, aber dies ist keine barrierefreie Lösung. Bildschirmleser sind nicht auf blinde Menschen beschränkt; auch Menschen mit eingeschränkter Sehkraft und sogar perfektem Sehen nutzen sie. Aus diesem Grund können Sie den Bildschirmleser nicht auf ein Element außerhalb des Bildschirms fokussieren lassen.
Unten ist das endgültige Ergebnis all dieser Änderungen (Sie werden ein besseres Gefühl dafür bekommen, wenn Sie es mit einer unterstützenden Technologie wie NVDA oder VoiceOver ausprobieren).
Live-Beispiel
Den vollständigen Quellcode hier ansehen.
Wenn Sie weitermachen möchten, muss der Code in diesem Beispiel verbessert werden, bevor er generisch und wiederverwendbar wird. Dies ist eine Übung, die Sie ausprobieren können. Zwei Hinweise, die Ihnen dabei helfen: Das erste Argument für alle unsere Funktionen ist dasselbe, was bedeutet, dass diese Funktionen denselben Kontext benötigen. Es wäre ratsam, ein Objekt zu erstellen, um diesen Kontext zu teilen.
Ein alternativer Ansatz: Verwendung von Radio-Buttons
Im obigen Beispiel haben wir ein <select>
-Element mit nicht-semantischem HTML, CSS und JavaScript neu erfunden. Dieses Auswahlfeld hat eine Option aus einer begrenzten Anzahl von Optionen ausgewählt, was dieselbe Funktionalität wie eine gleichnamige Gruppe von radio-Buttons hat.
Wir könnten dies daher mit Radio-Buttons neu erfinden; schauen wir uns diese Option an.
Wir können mit einer vollständig semantischen, zugänglichen, ungeordneten Liste von radio-Buttons beginnen, die mit einem zugehörigen <label>
, das die ganze Gruppe mit einem semantisch passenden <fieldset>
und <legend>
-Paar beschriftet.
<fieldset>
<legend>Pick a fruit</legend>
<ul class="styledSelect">
<li>
<input
type="radio"
name="fruit"
value="Cherry"
id="fruitCherry"
checked />
<label for="fruitCherry">Cherry</label>
</li>
<li>
<input type="radio" name="fruit" value="Lemon" id="fruitLemon" />
<label for="fruitLemon">Lemon</label>
</li>
<li>
<input type="radio" name="fruit" value="Banana" id="fruitBanana" />
<label for="fruitBanana">Banana</label>
</li>
<li>
<input
type="radio"
name="fruit"
value="Strawberry"
id="fruitStrawberry" />
<label for="fruitStrawberry">Strawberry</label>
</li>
<li>
<input type="radio" name="fruit" value="Apple" id="fruitApple" />
<label for="fruitApple">Apple</label>
</li>
</ul>
</fieldset>
Wir werden ein wenig Styling der Radio-Button-Liste (nicht des legend/fieldset) vornehmen, um sie ähnlich wie das vorherige Beispiel aussehen zu lassen, nur um zu zeigen, dass es möglich ist:
.styledSelect {
display: inline-block;
padding: 0;
}
.styledSelect li {
list-style-type: none;
padding: 0;
display: flex;
}
.styledSelect [type="radio"] {
position: absolute;
left: -100vw;
top: -100vh;
}
.styledSelect label {
margin: 0;
line-height: 2;
padding: 0 0 0 4px;
}
.styledSelect:not(:focus-within) input:not(:checked) + label {
height: 0;
outline-color: transparent;
overflow: hidden;
}
.styledSelect:not(:focus-within) input:checked + label {
border: 0.2em solid #000;
border-radius: 0.4em;
box-shadow: 0 0.1em 0.2em rgb(0 0 0 / 45%);
}
.styledSelect:not(:focus-within) input:checked + label::after {
content: "▼";
background: black;
float: right;
color: white;
padding: 0 4px;
margin: 0 -4px 0 4px;
}
.styledSelect:focus-within {
border: 0.2em solid #000;
border-radius: 0.4em;
box-shadow: 0 0.1em 0.2em rgb(0 0 0 / 45%);
}
.styledSelect:focus-within input:checked + label {
background-color: #333;
color: #fff;
width: 100%;
}
Ganz ohne JavaScript und mit nur ein wenig CSS können wir die Liste der Radio-Buttons so stylen, dass nur das ausgewählte Element angezeigt wird. Wenn sich der Fokus innerhalb des <ul>
im <fieldset>
befindet, öffnet sich die Liste, und die Aufwärts- und Abwärtspfeile (sowie links und rechts) funktionieren, um die vorherigen und nächsten Elemente auszuwählen. Probieren Sie es aus:
Das funktioniert, bis zu einem gewissen Grad, ohne JavaScript. Wir haben ein ähnliches Steuerelement zu unserem benutzerdefinierten Steuerelement erstellt, das auch dann funktioniert, wenn JavaScript fehlschlägt. Scheint eine großartige Lösung zu sein, oder? Nun, nicht 100%. Es funktioniert mit der Tastatur, aber nicht wie erwartet mit einem Mausklick. Es macht wahrscheinlich mehr Sinn, Webstandards als Basis für benutzerdefinierte Steuerelemente zu verwenden, anstatt sich auf Frameworks zu verlassen, um Elemente ohne native Semantik zu erstellen. Unser Steuerelement hat allerdings nicht die gleiche Funktionalität wie ein nativer <select>
.
Auf der positiven Seite ist dieses Steuerelement vollständig für einen Bildschirmleser zugänglich und vollständig über die Tastatur navigierbar. Dieses Steuerelement ersetzt jedoch kein <select>
. Einige Funktionalitäten unterscheiden sich und/oder fehlen. Zum Beispiel navigieren alle vier Pfeile durch die Optionen, aber wenn der Benutzer sich auf den letzten Button befindet und den Abwärtspfeil klickt, gelangt er zum ersten Button; es stoppt nicht am Anfang und Ende der Auswahlliste wie ein <select>
.
Wir überlassen das Hinzufügen dieser fehlenden Funktionalität als Übung für den Leser.
Fazit
Wir haben alle Grundlagen zur Erstellung eines benutzerdefinierten Formularelements gesehen, aber wie Sie sehen, ist es nicht trivial. Bevor Sie Ihr eigenes benutzerdefiniertes Steuerelement erstellen, sollten Sie überlegen, ob HTML alternative Elemente bietet, die Ihre Anforderungen angemessen unterstützen können. Wenn Sie ein benutzerdefiniertes Steuerelement erstellen müssen, ist es oft einfacher, auf Drittanbieter-Bibliotheken zurückzugreifen, anstatt Ihr eigenes zu erstellen. Aber wenn Sie Ihr eigenes erstellen, bestehende Elemente ändern oder ein Framework verwenden, um ein vorkonfektioniertes Steuerelement zu implementieren, denken Sie daran, dass es komplizierter ist, ein benutzerfreundliches und zugängliches Formularelement zu erstellen, als es aussieht.
Hier sind einige Bibliotheken, die Sie in Betracht ziehen sollten, bevor Sie Ihre eigene programmieren:
Wenn Sie alternative Steuerelemente über Radio-Buttons, Ihr eigenes JavaScript oder mit einer Drittanbieter-Bibliothek erstellen, stellen Sie sicher, dass sie zugänglich und zukunftssicher sind; das bedeutet, dass sie besser mit einer Vielzahl von Browsern arbeiten müssen, deren Kompatibilität mit den Webstandards, die sie verwenden, variiert. Viel Spaß!