<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();
This Pen doesn't use any external JavaScript resources.