<div id="dropdown-11048-32880" class="dropdown has-overlay-link">
    <fieldset class="dropdown__fieldset">
        <legend class="dropdown__label" id="dropdown-11048-32880-label">Du bist gerade</legend>

        <button type="button" class="dropdown__toggle u-overlay-link" aria-labelledby="dropdown-11048-32880-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-11048-32880-options" class="dropdown__options" hidden aria-labelledby="dropdown-11048-32880-legend">
            <li class="dropdown__option has-underline" role="presentation">
                <input type="radio" id="dropdown-11048-32880-1" class="dropdown__option-radio" name="targetGroup" value="1" tabindex="-1">

                <label for="dropdown-11048-32880-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-11048-32880-2" class="dropdown__option-radio" name="targetGroup" value="2" tabindex="-1">

                <label for="dropdown-11048-32880-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-11048-32880-3" class="dropdown__option-radio" name="targetGroup" value="3" tabindex="-1">

                <label for="dropdown-11048-32880-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-11048-32880-4" class="dropdown__option-radio" name="targetGroup" value="4" tabindex="-1">

                <label for="dropdown-11048-32880-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
    }
  ]
}
  • Content:
    :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);
    }
    
  • URL: /components/raw/dropdown/dropdown.scss
  • Filesystem Path: src/components/1-atoms/dropdown/dropdown.scss
  • Size: 2.8 KB
  • Content:
    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));
    
  • URL: /components/raw/dropdown/dropdown.ts
  • Filesystem Path: src/components/1-atoms/dropdown/dropdown.ts
  • Size: 9.4 KB

No notes defined.