<div id="dropdown-51837-77915" class="dropdown dropdown--frameless has-overlay-link">
<fieldset class="dropdown__fieldset">
<legend class="dropdown__label" id="dropdown-51837-77915-label">Du bist gerade</legend>
<button type="button" class="dropdown__toggle u-overlay-link" aria-labelledby="dropdown-51837-77915-legend" tabindex="-1">
<div class="dropdown__toggle-inner">
<span class="dropdown__toggle-text u-underline">Auswählen</span>
<svg class="icon icon--caret-down dropdown__toggle-icon" viewBox="0 0 200 200" aria-hidden="true">
<use xlink:href="/assets/icons/icons.3bafb6df0d.svg#caret-down"></use>
</svg>
</div>
</button>
<ul id="dropdown-51837-77915-options" class="dropdown__options" hidden aria-labelledby="dropdown-51837-77915-legend">
<li class="dropdown__option has-underline" role="presentation">
<input type="radio" id="dropdown-51837-77915-1" class="dropdown__option-radio" name="targetGroup" value="1" tabindex="-1">
<label for="dropdown-51837-77915-1" class="dropdown__option-label u-underline" role="option">Vor dem Studium</label>
</li>
<li class="dropdown__option has-underline" role="presentation">
<input type="radio" id="dropdown-51837-77915-2" class="dropdown__option-radio" name="targetGroup" value="2" tabindex="-1">
<label for="dropdown-51837-77915-2" class="dropdown__option-label u-underline" role="option">Im Studium</label>
</li>
<li class="dropdown__option has-underline" role="presentation">
<input type="radio" id="dropdown-51837-77915-3" class="dropdown__option-radio" name="targetGroup" value="3" tabindex="-1">
<label for="dropdown-51837-77915-3" class="dropdown__option-label u-underline" role="option">Kurz vor Berufseinstieg</label>
</li>
<li class="dropdown__option has-underline" role="presentation">
<input type="radio" id="dropdown-51837-77915-4" class="dropdown__option-radio" name="targetGroup" value="4" tabindex="-1">
<label for="dropdown-51837-77915-4" class="dropdown__option-label u-underline" role="option">Im Berufsleben</label>
</li>
</ul>
</fieldset>
</div>
{% set id = id ??? html_id('dropdown') -%}
<div {{ html_attributes({
id: id ?? false,
class: {
'dropdown': true,
'dropdown--frameless': frameless ?? false,
'has-overlay-link': true,
},
}, attrs ?? {}) }}>
<fieldset class="dropdown__fieldset">
<legend class="dropdown__label" id="{{ 'label' | namespaceInputId(id) }}">
{{- label -}}
</legend>
<button {{ html_attributes({
type: 'button',
class: 'dropdown__toggle u-overlay-link',
'aria-labelledby': 'legend' | namespaceInputId(id),
tabindex: '-1',
}) }}>
<div class="dropdown__toggle-inner">
<span class="dropdown__toggle-text u-underline">
{{- selectText -}}
</span>
{% include '@icon' with {
icon: 'caret-down',
class: 'dropdown__toggle-icon',
} only %}
</div>
</button>
<ul {{ html_attributes({
id: 'options' | namespaceInputId(id),
class: 'dropdown__options',
hidden: true,
'aria-labelledby': 'legend' | namespaceInputId(id),
}) }}>
{% for option in options %}
<li class="dropdown__option has-underline" role="presentation">
<input {{ html_attributes({
type: 'radio',
id: option.value | namespaceInputId(id),
class: 'dropdown__option-radio',
name: name,
value: option.value,
checked: option.checked|default ? 'checked',
tabindex: '-1',
}) }}>
<label {{ html_attributes({
for: option.value | namespaceInputId(id),
class: {
'dropdown__option-label': true,
'dropdown__option-label--selected': option.checked|default,
'u-underline': true,
},
role: 'option',
'aria-selected': option.checked|default ? 'true',
}) }}>
{{- option.label -}}
</label>
</li>
{% endfor %}
</ul>
</fieldset>
</div>
{
"label": "Du bist gerade",
"selectText": "Auswählen",
"name": "targetGroup",
"options": [
{
"value": "1",
"label": "Vor dem Studium",
"checked": false
},
{
"value": "2",
"label": "Im Studium",
"checked": false
},
{
"value": "3",
"label": "Kurz vor Berufseinstieg",
"checked": false
},
{
"value": "4",
"label": "Im Berufsleben",
"checked": false
}
],
"frameless": true
}
:root {
--dropdown-background-color: var(--color-white);
--dropdown-border-color: var(--color-midnight);
--dropdown-border-width: 2px;
--dropdown-color: var(--color-midnight);
--dropdown-font-size: 2.4rem;
--dropdown-height: 8.4rem;
--dropdown-label-color: var(--color-grey-dark);
--dropdown-label-font-size: 0.55em;
--dropdown-padding-block: 1.6rem;
--dropdown-padding-inline: 2.4rem;
}
.dropdown {
background-color: var(--dropdown-background-color);
box-shadow: inset 0 0 0 var(--dropdown-border-width) var(--dropdown-border-color);
color: var(--dropdown-color);
font-size: var(--dropdown-font-size);
text-align: start;
user-select: none;
:focus-visible::before {
--focus-outline-offset: 0;
--focus-outline-width: 3px;
content: '';
inset: 0;
pointer-events: none;
position: absolute;
@include use-focus-outline();
}
}
.dropdown--frameless {
--dropdown-border-width: 0;
}
.dropdown__fieldset {
block-size: var(--dropdown-height);
display: flex;
flex-direction: column;
gap: 1rem;
inline-size: 100%;
justify-content: center;
min-inline-size: 0;
padding-block: var(--dropdown-padding-block);
padding-inline: var(--dropdown-padding-inline);
position: relative;
}
.dropdown__label {
color: var(--dropdown-label-color);
display: block;
float: left;
font-size: var(--dropdown-label-font-size);
padding-inline: 0;
}
.dropdown__toggle {
--focus-outline-width: 0;
block-size: 100%;
display: block;
inline-size: 100%;
text-align: start;
}
.dropdown__toggle-inner {
align-items: center;
display: flex;
max-inline-size: 100%;
min-inline-size: 0;
}
.dropdown__toggle-text {
font-weight: var(--font-weight-bold);
margin-block-end: -0.5rem;
overflow: hidden;
padding-block-end: 0.5rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown__toggle-icon {
flex-shrink: 0;
font-size: 0.7em;
margin-inline-start: 1rem;
}
.dropdown__options {
animation: opacity var(--duration-default);
background-color: var(--dropdown-background-color);
border: var(--dropdown-border-width) solid var(--dropdown-border-color);
border-block-start: 0;
display: grid;
gap: 2rem;
inset-block-start: 40%;
inset-inline: 0;
padding-block-end: calc(var(--dropdown-padding-block) * 2);
padding-block-start: var(--dropdown-padding-block);
padding-inline: var(--dropdown-padding-inline);
position: absolute;
z-index: z-index('dropdown');
&[hidden] {
display: none;
}
}
.dropdown__option {
display: block;
}
.dropdown__option-radio {
@include use-hidden-visually();
}
.dropdown__option-label {
cursor: pointer;
display: block;
}
.dropdown__option-label--selected {
font-weight: var(--font-weight-bold);
}
import getIndexByLetter from '../../../javascripts/utils/getIndexByLetter';
import invisibleFocus from '../../../javascripts/utils/invisibleFocus';
enum DropdownAction {
Close = 0,
CloseSelect = 1,
First = 2,
Last = 3,
Next = 4,
Open = 5,
PageDown = 6,
PageUp = 7,
Previous = 8,
Select = 9,
Type = 10,
}
class Dropdown {
$dropdown: HTMLElement;
$button: HTMLButtonElement;
$options: HTMLElement;
$$option: HTMLLabelElement[];
$selectedOption: HTMLLabelElement | null;
activeIndex: number | null;
open = false;
searchString = '';
searchTimeout: number | undefined;
ignoreBlur = false;
constructor($dropdown: HTMLElement) {
this.$dropdown = $dropdown;
const $button =
$dropdown.querySelector<HTMLButtonElement>('.dropdown__toggle');
if (!$button) {
throw new Error('.dropdown__toggle is missing in dropdown');
}
const $options = $dropdown.querySelector<HTMLElement>('.dropdown__options');
if (!$options) {
throw new Error('.dropdown__options is missing in dropdown');
}
this.$button = $button;
this.$options = $options;
// Find all options
this.$$option = [
...$options.querySelectorAll<HTMLLabelElement>('[role="option"]'),
];
// Get active option
const selectedIndex = this.$$option.findIndex(
($option) => $option.getAttribute('aria-selected') === 'true',
);
this.activeIndex = selectedIndex === -1 ? null : selectedIndex;
// eslint-disable-next-line security/detect-object-injection
this.$selectedOption = this.activeIndex
? this.$$option[this.activeIndex]
: null;
this.init();
}
init() {
// Add aria-roles and add tab index to button
this.$button.setAttribute('role', 'combobox');
this.$button.setAttribute('aria-haspopup', 'listbox');
this.$button.setAttribute('aria-expanded', 'false');
this.$button.setAttribute('aria-controls', this.$options.id);
this.$button.setAttribute('tabindex', '0');
// Add aria-roles and add tab index to options
this.$options.setAttribute('role', 'listbox');
this.$options.setAttribute('tabindex', '-1');
// Add click handlers
this.$button.addEventListener('blur', this.onComboBlur.bind(this));
this.$button.addEventListener('click', this.onComboClick.bind(this));
this.$button.addEventListener('keydown', this.onComboKeyDown.bind(this));
// Add click handler to options
this.$$option.forEach(($option, index) => {
$option.addEventListener('click', (event) => {
event.preventDefault();
this.onOptionClick(index);
});
$option.addEventListener('mousedown', () => {
this.onOptionMouseDown();
});
});
}
getSearchString(char: string) {
if (typeof this.searchTimeout === 'number') {
window.clearTimeout(this.searchTimeout);
}
this.searchTimeout = window.setTimeout(() => {
this.searchString = '';
}, 500);
this.searchString += char;
return this.searchString;
}
onComboBlur() {
if (this.ignoreBlur) {
this.ignoreBlur = false;
return;
}
if (this.open) {
if (this.activeIndex) {
this.onOptionChange(this.activeIndex);
}
this.updateMenuState(false, false);
}
}
onComboClick() {
invisibleFocus(this.$button);
this.updateMenuState(!this.open, false);
}
onComboKeyDown(event: KeyboardEvent) {
const { key } = event;
const max = this.$$option.length - 1;
// eslint-disable-next-line security/detect-non-literal-fs-filename
const action = this.#getActionFromKey(event, this.open);
switch (action) {
case DropdownAction.Last:
case DropdownAction.First:
this.updateMenuState(true);
break;
case DropdownAction.Next:
case DropdownAction.Previous:
case DropdownAction.PageUp:
case DropdownAction.PageDown:
event.preventDefault();
this.onOptionChange(
this.#getUpdatedIndex(this.activeIndex ?? -1, max, action),
);
break;
case DropdownAction.CloseSelect:
event.preventDefault();
if (this.activeIndex) {
this.onOptionChange(this.activeIndex);
this.selectOption(this.activeIndex);
}
this.updateMenuState(false);
break;
case DropdownAction.Close:
event.preventDefault();
this.updateMenuState(false);
break;
case DropdownAction.Type:
this.onComboType(key);
break;
case DropdownAction.Open:
event.preventDefault();
this.updateMenuState(true);
break;
default:
// Do nothing on other keys
break;
}
}
onComboType(letter: string) {
// 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.$$option.map(($option) => $option.innerText),
searchString,
(this.activeIndex ?? 0) + 1,
);
// if a match was found, go to it
if (searchIndex >= 0) {
this.onOptionChange(searchIndex);
} else {
window.clearTimeout(this.searchTimeout);
this.searchString = '';
}
}
onOptionChange(activeIndex: number) {
this.activeIndex = activeIndex;
// eslint-disable-next-line security/detect-object-injection
const $activeOption = this.$$option[activeIndex]; // nosemgrep: eslint.detect-object-injection
this.$button.setAttribute('aria-activedescendant', $activeOption.id);
this.$$option.forEach(($option) => {
$option.classList.toggle(
'dropdown__option-label--selected',
$option === $activeOption,
);
});
}
onOptionClick(activeIndex: number) {
this.onOptionChange(activeIndex);
this.selectOption(activeIndex);
this.updateMenuState(false);
}
onOptionMouseDown() {
this.ignoreBlur = true;
}
selectOption(activeIndex: number) {
// eslint-disable-next-line security/detect-object-injection
const $activeOption = this.$$option[activeIndex]; // nosemgrep: eslint.detect-object-injection
this.activeIndex = activeIndex;
this.$selectedOption = $activeOption;
const $toggleText = this.$button.querySelector<HTMLElement>(
'.dropdown__toggle-text',
);
if ($toggleText) {
$toggleText.innerText = $activeOption.innerText;
}
this.$$option.forEach(($option) => {
$option.setAttribute(
'aria-selected',
$option === $activeOption ? 'true' : 'false',
);
});
const $radioButton = document.getElementById($activeOption.htmlFor);
if ($radioButton) {
($radioButton as HTMLInputElement).checked = true;
}
}
updateMenuState(open: boolean, callFocus = true) {
if (this.open === open) {
return;
}
this.open = open;
this.$button.setAttribute('aria-expanded', open ? 'true' : 'false');
this.$options.toggleAttribute('hidden', !open);
const activeId = open ? this.$selectedOption?.id : null;
this.$button.setAttribute('aria-activedescendant', activeId ?? '');
if (callFocus) {
invisibleFocus(this.$button);
}
}
// eslint-disable-next-line class-methods-use-this
#getUpdatedIndex(
currentIndex: number,
maxIndex: number,
action: DropdownAction,
) {
const pageSize = 10;
switch (action) {
case DropdownAction.First:
return 0;
case DropdownAction.Last:
return maxIndex;
case DropdownAction.Previous:
return Math.max(0, currentIndex - 1);
case DropdownAction.Next:
return Math.min(maxIndex, currentIndex + 1);
case DropdownAction.PageUp:
return Math.max(0, currentIndex - pageSize);
case DropdownAction.PageDown:
return Math.min(maxIndex, currentIndex + pageSize);
default:
return currentIndex;
}
}
// eslint-disable-next-line class-methods-use-this
#getActionFromKey(
event: KeyboardEvent,
menuOpen: boolean,
): DropdownAction | null {
const { key, altKey, ctrlKey, metaKey } = event;
const openKeys = ['ArrowDown', 'ArrowUp', 'Enter', ' '];
if (!menuOpen && openKeys.includes(key)) {
return DropdownAction.Open;
}
if (key === 'Home') {
return DropdownAction.First;
}
if (key === 'End') {
return DropdownAction.Last;
}
if (
key === 'Backspace' ||
key === 'Clear' ||
(key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey)
) {
return DropdownAction.Type;
}
if (menuOpen) {
if (key === 'ArrowUp' && altKey) {
return DropdownAction.CloseSelect;
}
if (key === 'ArrowDown' && !altKey) {
return DropdownAction.Next;
}
if (key === 'ArrowUp') {
return DropdownAction.Previous;
}
if (key === 'PageUp') {
return DropdownAction.PageUp;
}
if (key === 'PageDown') {
return DropdownAction.PageDown;
}
if (key === 'Escape') {
return DropdownAction.Close;
}
if (key === 'Enter' || key === ' ') {
return DropdownAction.CloseSelect;
}
}
return null;
}
}
document
.querySelectorAll<HTMLElement>('.dropdown')
.forEach(($dropdown) => new Dropdown($dropdown));
No notes defined.