<div class="ds-rows --indent-medium">
  <div class="ds-field">
    <!-- Label -->
    <label class="ds-text --label" for="autocomplete-filter">API</label>

    <!-- Autocomplete -->
    <div class="ds-autocomplete">
      <!-- Selected Option -->
      <dl class="ds-details --vertical" v-if="selectedOption != null">
        <dt class="--no-case">{{ selectedOption.name }}</dt>
        <dd>
          <div class="ds-text --small">{{ selectedOption.description }}</div>
        </dd>
        <dd>
          <div class="ds-cols --middle --spaced --indent-small">
            <div :class="['ds-tag', '--outline', methodColorClass(selectedOption.method)]">
              <span>{{ selectedOption.method }}</span>
            </div>
            <div class="ds-text --code --small --color-blue">{{ selectedOption.url }}</div>
          </div>
        </dd>
      </dl>

      <!-- Filter -->
      <div class="ds-input --icon-search">
        <input type="text" id="autocomplete-filter" v-model="searchTerm" @click="openList" @keydown="handleKeydown"
          role="combobox" :aria-expanded="listIsOpen" aria-autocomplete="list" aria-controls="autocomplete-listbox"
          aria-activedescendant="activeDescendantId" aria-haspopup="grid" />
      </div>

      <!-- Button -->
      <button class="ds-button --plain --addon --icon-ui-arrow-down --hide-label --top-left" @click="toggleListFromButton"
        :aria-expanded="listIsOpen" aria-label="Show API options" aria-controls="autocomplete-listbox">
        <span>Show API options</span>
      </button>

      <!-- Grid -->
      <div class="ds-menu" id="autocomplete-listbox" role="grid" :aria-hidden="!listIsOpen"
        style="--ds-menu-max-height: 420px">
        <ul role="group" v-for="(api, apiIndex) in filteredApis" :key="api.apiName"
          :aria-labelledby="'group-' + apiIndex">
          <li v-if="api.options.length" role="presentation" class="ds-text --bold" :id="'group-' + apiIndex">
            {{ api.apiName }}
          </li>
          <li role="row" v-for="(option, optionIndex) in api.options" :key="option.name"
            :id="'option-' + computeOptionIndex(apiIndex, optionIndex)" :aria-selected="isItemSelected(option)"
            :class="{ '--focused': focusedIndex === computeOptionIndex(apiIndex, optionIndex) }"
            @click="selectItem(option)" @mouseover="handleMouseOver(computeOptionIndex(apiIndex, optionIndex))">
            <dl class="ds-details --vertical">
              <dt class="--no-case" role="gridcell">{{ option.name }}</dt>
              <dd>
                <div class="ds-text --small" role="gridcell">{{ option.description }}</div>
              </dd>
              <dd>
                <div class="ds-cols --middle --spaced --indent-small">
                  <div :class="['ds-tag', '--outline', methodColorClass(option.method)]" role="gridcell">
										<span>{{ option.method }}</span>
									</div>
                  <div class="ds-text --code --small --color-blue" role="gridcell">{{ option.url }}</div>
                </div>
              </dd>
            </dl>
          </li>
        </ul>
      </div>
    </div>
  </div>
</div>
import { createApp } from 'https://unpkg.com/petite-vue?module';
let apis = [
  {
    apiName: 'billing',
    options: [
      {
        name: 'getBillingHistory',
        description: 'Gets a list of finalized past billing history',
        method: 'GET',
        url: '/v1/bills',
      },
      {
        name: 'getBilling',
        description: 'Gets the current billing information',
        method: 'GET',
        url: '/v1/bills/{year}/{month}',
      },
      {
        name: 'getBillingPerDay',
        description: 'Gets the billing details per day for a specific month',
        method: 'GET',
        url: '/v1/bills/{year}/{month}/daily',
      },
      {
        name: 'getBillingGroup',
        description: 'Gets billing information for a specific group',
        method: 'GET',
        url: '/v1/billing/groups/{groupId}',
      },
      {
        name: 'getPaymentStatus',
        description: 'Gets the payment status for a specific bill',
        method: 'GET',
        url: '/v1/bills/{year}/{month}/payment_status',
      },
      {
        name: 'getBillDetails',
        description: 'Gets detailed billing information for a specific bill',
        method: 'GET',
        url: '/v1/bills/{year}/{month}/details',
      },
      {
        name: 'getBillPdf',
        description: 'Downloads the PDF version of the billing statement',
        method: 'GET',
        url: '/v1/bills/{year}/{month}/pdf',
      },
      {
        name: 'getUsageSummary',
        description: 'Gets a summary of usage for billing',
        method: 'GET',
        url: '/v1/billing/usage_summary',
      },
    ],
  },
  {
    apiName: 'auditLog',
    options: [
      { name: 'listAuditLogs', description: 'Retrieves a list of audit logs', method: 'GET', url: '/v1/audit_logs' },
      {
        name: 'getAuditLogDetails',
        description: 'Retrieves details of a specific audit log entry',
        method: 'GET',
        url: '/v1/audit_logs/{auditLogId}',
      },
      {
        name: 'getSupportedActionTypes',
        description: 'Lists the different types of actions that can be audited',
        method: 'GET',
        url: '/v1/audit_logs/action_types',
      },
    ],
  },
  {
    apiName: 'auth',
    options: [
      {
        name: 'authenticate',
        description: 'Generates an API token by authenticating the user',
        method: 'POST',
        url: '/v1/auth',
      },
      {
        name: 'verifyAuthToken',
        description: 'Verifies the validity of an API token',
        method: 'GET',
        url: '/v1/auth/verify',
      },
      { name: 'logout', description: 'Invalidates the current API token', method: 'POST', url: '/v1/auth/logout' },
    ],
  },
  {
    apiName: 'payment',
    options: [
      {
        name: 'getPaymentMethods',
        description: 'Lists available payment methods',
        method: 'GET',
        url: '/v1/payment/methods',
      },
      {
        name: 'addPaymentMethod',
        description: 'Adds a new payment method',
        method: 'POST',
        url: '/v1/payment/methods',
      },
      {
        name: 'deletePaymentMethod',
        description: 'Deletes a payment method',
        method: 'DELETE',
        url: '/v1/payment/methods/{methodId}',
      },
    ],
  },
  {
    apiName: 'orders',
    options: [
      { name: 'listOrders', description: 'Retrieves a list of orders', method: 'GET', url: '/v1/orders' },
      {
        name: 'getOrderDetails',
        description: 'Retrieves details of a specific order',
        method: 'GET',
        url: '/v1/orders/{orderId}',
      },
      { name: 'createOrder', description: 'Creates a new order', method: 'POST', url: '/v1/orders' },
      { name: 'cancelOrder', description: 'Cancels an order', method: 'DELETE', url: '/v1/orders/{orderId}' },
    ],
  },
];

createApp({
  apis: apis,
  searchTerm: '',
  listIsOpen: false,
  selectedOption: null,
  focusedIndex: -1,

  get flattenedOptions() {
    return this.filteredApis.flatMap((api) => api.options);
  },

  get filteredApis() {
    const term = this.searchTerm.toLowerCase();
    return this.apis.map((api) => ({
      apiName: api.apiName,
      options: api.options.filter(
        (option) => option.name.toLowerCase().includes(term) || option.description.toLowerCase().includes(term)
      ),
    }));
  },

  toggleList() {
    this.listIsOpen = !this.listIsOpen;
    this.setFocusIndex();
  },

  toggleListFromButton() {
    this.toggleList();
    this.focusInput();
    if (this.listIsOpen) {
      this.setFocusIndex();
    }
  },

  get activeDescendantId() {
    return this.focusedIndex !== -1 ? `option-${this.focusedIndex}` : '';
  },

  handleKeydown(event) {
    const itemCount = this.flattenedOptions.length;

    switch (event.key) {
      case 'Enter':
        if (this.listIsOpen && this.focusedIndex > -1 && this.focusedIndex < itemCount) {
          this.selectItem(this.flattenedOptions[this.focusedIndex]);
          this.closeList();
        } else {
          this.openList();
        }
        event.preventDefault();
        break;

      case 'Escape':
        if (this.listIsOpen) {
          this.closeList();
        } else {
          this.searchTerm = ''; // Clear the textbox
        }
        event.preventDefault();
        break;

      case 'ArrowDown':
        if (!this.listIsOpen) {
          this.openList();
        } else {
          this.focusedIndex = (this.focusedIndex + 1) % itemCount;
          this.scrollToFocusedElement();
        }
        event.preventDefault();
        break;

      case 'ArrowUp':
        if (!this.listIsOpen) {
          this.openList();
        } else {
          this.focusedIndex = (this.focusedIndex - 1 + itemCount) % itemCount;
          this.scrollToFocusedElement();
        }
        event.preventDefault();
        break;

      case 'ArrowRight':
      case 'ArrowLeft':
        this.focusedIndex = -1;
        break;

      case 'Home':
        this.focusedIndex = -1;
        event.target.setSelectionRange(0, 0);
        event.preventDefault();
        break;

      case 'End':
        this.focusedIndex = -1;
        event.target.setSelectionRange(this.searchTerm.length, this.searchTerm.length);
        event.preventDefault();
        break;

      case 'Delete':
      case 'Backspace':
        if (!this.searchTerm && this.selectedOption) {
          this.selectedOption = null; // Clear the previously chosen value
          event.preventDefault();
        }
        break;

      default:
        this.openList();
    }
  },

  selectItem(item) {
    this.selectedOption = item;
    this.searchTerm = ''; // Clear the input after selecting
    this.closeList();
    this.focusedIndex = -1;
  },

  openList() {
    this.listIsOpen = true;
    this.setFocusIndex(); // Set focus index when opening the list
  },

  closeList() {
    this.listIsOpen = false;
    this.focusedIndex = -1;
  },

  setFocusIndex() {
    if (this.selectedOption) {
      const index = this.flattenedOptions.findIndex((option) => option.name === this.selectedOption.name);
      this.focusedIndex = index !== -1 ? index : 0; // Focus on selected, or default to 0
      this.scrollToFocusedElement();
    } else {
      this.focusedIndex = 0;
    }
  },

  handleMouseOver(optionIndex) {
    this.focusedIndex = optionIndex;
    this.scrollToFocusedElement();
  },

  scrollToFocusedElement() {
    const focusedElement = document.querySelector(`#option-${this.focusedIndex}`);
    if (focusedElement) {
      focusedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
    }
  },

  focusInput() {
    const inputElement = document.querySelector('[role="combobox"]');
    if (inputElement) {
      inputElement.focus();
    }
  },

  isItemSelected(item) {
    return this.selectedOption && this.selectedOption.name === item.name;
  },

  computeOptionIndex(apiIndex, optionIndex) {
    let index = 0;
    for (let i = 0; i < apiIndex; i++) {
      index += this.filteredApis[i].options.length;
    }
    return index + optionIndex;
  },

  methodColorClass(method) {
    switch (method.toLowerCase()) {
      case 'get':
        return '--color-blue';
      case 'post':
        return '--color-green';
      case 'put':
        return '--color-yellow';
      case 'delete':
        return '--color-red';
      default:
        return '';
    }
  },
}).mount();

External CSS

  1. https://assets.soracom.io/sds/beta/combined/style.css?version=1.0.6

External JavaScript

This Pen doesn't use any external JavaScript resources.