diff --git a/demo/index.html b/demo/index.html
index 6faa31e..5b14c91 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -64,6 +64,7 @@
@@include('./partials/tooltips.html')
@@include('./partials/dropdowns.html')
@@include('./partials/overlays.html')
+ @@include('./partials/advanced-select.html')
@@include('./partials/navigation-shell.html')
@@include('./partials/backgrounds.html')
@@include('./partials/breadcrumbs.html')
diff --git a/demo/partials/advanced-select.html b/demo/partials/advanced-select.html
new file mode 100644
index 0000000..a1697b7
--- /dev/null
+++ b/demo/partials/advanced-select.html
@@ -0,0 +1,34 @@
+
diff --git a/src/js/components/advanced-select.js b/src/js/components/advanced-select.js
new file mode 100644
index 0000000..d3d0ecc
--- /dev/null
+++ b/src/js/components/advanced-select.js
@@ -0,0 +1,254 @@
+/**
+ * advancedSelect browser helper.
+ *
+ * Mirrors gnexus-ui-kit advancedSelect public surface.
+ *
+ * @param {HTMLInputElement} input - Text input to enhance
+ * @param {object} options - Map of value => displayValue
+ * @param {string} [notFoundText='Nothing found'] - Empty state message
+ * @returns {HTMLElement} Advanced select container
+ */
+
+function scrollToElementInFocus(container) {
+ const focus = container.querySelector(".option.focus");
+ if(!focus) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const focusRect = focus.getBoundingClientRect();
+
+ if(focusRect.top < containerRect.top) {
+ container.scrollTop -= (containerRect.top - focusRect.top);
+ } else if(focusRect.bottom > containerRect.bottom) {
+ container.scrollTop += (focusRect.bottom - containerRect.bottom);
+ }
+}
+
+function autoSetState(container) {
+ const totalViewed = container.advancedSelect.optionsElements.length - container.querySelectorAll(".option.hide").length;
+ if(totalViewed === 0) {
+ container.advancedSelect.showState("not-found");
+ } else {
+ container.advancedSelect.showState("options");
+ }
+}
+
+function firstVisibleOption(container) {
+ return container.querySelector(".option:not(.hide)");
+}
+
+function lastVisibleOption(container) {
+ const options = container.querySelectorAll(".option:not(.hide)");
+ return options[options.length - 1] || null;
+}
+
+function selectOption(input, container, option) {
+ if(!option) {
+ return;
+ }
+
+ input.value = option.dataset.displayValue;
+ input.blur();
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ container.advancedSelect.dispatchEvent("selected");
+ container.advancedSelect.closeList();
+}
+
+function existsOption(value, options) {
+ for(const optionValue in options) {
+ if(options[optionValue] === value) {
+ return { [optionValue]: options[optionValue] };
+ }
+ }
+ return false;
+}
+
+export default function advancedSelect(input, options, notFoundText) {
+ const wrapper = document.createElement("div");
+ wrapper.className = "advanced-select-container";
+ input.parentElement?.insertBefore(wrapper, input);
+ wrapper.append(input);
+
+ const container = document.createElement("div");
+ container.className = "advanced-select";
+
+ const popup = document.createElement("div");
+ popup.className = "popup-options-container";
+
+ const notFound = document.createElement("div");
+ notFound.className = "not-found";
+ notFound.textContent = notFoundText ?? "Nothing found";
+
+ const optionsContainer = document.createElement("div");
+ optionsContainer.className = "options";
+
+ for(const optionValue in options) {
+ const option = document.createElement("div");
+ option.className = "option";
+ option.dataset.value = optionValue;
+ option.dataset.displayValue = options[optionValue];
+ option.textContent = options[optionValue];
+ optionsContainer.append(option);
+ }
+
+ popup.append(notFound, optionsContainer);
+ container.append(popup);
+ wrapper.append(container);
+
+ container.advancedSelect = {
+ isOpened: false,
+ options,
+ eventsHandlers: {
+ openList: [],
+ closeList: [],
+ selected: [],
+ changed: []
+ },
+ openList: () => {
+ container.advancedSelect.isOpened = true;
+ container.classList.add("a-show");
+ autoSetState(container);
+ container.advancedSelect.dispatchEvent("openList");
+ },
+ closeList: () => {
+ container.advancedSelect.isOpened = false;
+ container.classList.remove("a-show");
+ autoSetState(container);
+ container.advancedSelect.dispatchEvent("closeList");
+ },
+ showState: stateName => {
+ if(stateName === "options") {
+ container.querySelector(".options").classList.add("show");
+ container.querySelector(".not-found").classList.remove("show");
+ } else if(stateName === "not-found") {
+ container.querySelector(".options").classList.remove("show");
+ container.querySelector(".not-found").classList.add("show");
+ }
+ },
+ optionsElements: container.querySelectorAll(".option"),
+ value: () => {
+ const option = existsOption(input.value, options);
+ return {
+ inputValue: input.value,
+ isOption: option ? true : false,
+ option
+ };
+ },
+ addEventListener: (name, handler) => {
+ if(typeof container.advancedSelect.eventsHandlers[name] !== "undefined") {
+ container.advancedSelect.eventsHandlers[name].push(handler);
+ return;
+ }
+ console.error("Advanced Select component.", "addEventListener()", "Invalid event name");
+ },
+ dispatchEvent: name => {
+ if(typeof container.advancedSelect.eventsHandlers[name] === "undefined") {
+ return console.error("Advanced Select component.", "dispatchEvent()", "Invalid event name");
+ }
+
+ for(const eventHandler of container.advancedSelect.eventsHandlers[name]) {
+ eventHandler(container);
+ }
+ }
+ };
+
+ input.setAttribute("autocomplete", "nope");
+
+ input.advancedSelect = {
+ value: () => container.advancedSelect.value()
+ };
+
+ input.addEventListener("focus", () => {
+ container.advancedSelect.openList();
+ });
+
+ input.addEventListener("blur", () => {
+ requestAnimationFrame(() => {
+ if(!container.matches(":hover")) {
+ container.advancedSelect.closeList();
+ }
+ });
+ });
+
+ input.addEventListener("keydown", event => {
+ if(event.key === "ArrowUp") {
+ event.preventDefault();
+ const current = container.querySelector(".option.focus");
+ if(current) {
+ current.classList.remove("focus");
+ let prev = current.previousElementSibling;
+ while(prev) {
+ if(!prev.classList.contains("hide")) {
+ break;
+ }
+ prev = prev.previousElementSibling;
+ }
+ if(!prev) {
+ prev = lastVisibleOption(container);
+ }
+ prev?.classList.add("focus");
+ } else {
+ lastVisibleOption(container)?.classList.add("focus");
+ }
+ scrollToElementInFocus(container);
+ } else if(event.key === "ArrowDown") {
+ event.preventDefault();
+ const current = container.querySelector(".option.focus");
+ if(current) {
+ current.classList.remove("focus");
+ let next = current.nextElementSibling;
+ while(next) {
+ if(!next.classList.contains("hide")) {
+ break;
+ }
+ next = next.nextElementSibling;
+ }
+ if(!next) {
+ next = firstVisibleOption(container);
+ }
+ next?.classList.add("focus");
+ } else {
+ firstVisibleOption(container)?.classList.add("focus");
+ }
+ scrollToElementInFocus(container);
+ } else if(event.key === "Enter") {
+ event.preventDefault();
+ const selected = container.querySelector(".option.focus");
+ selectOption(input, container, selected);
+ } else if(event.key === "Escape") {
+ container.advancedSelect.closeList();
+ input.blur();
+ }
+ });
+
+ input.addEventListener("input", event => {
+ const val = event.currentTarget.value.toLowerCase();
+ if(val === "") {
+ container.advancedSelect.optionsElements.forEach(i => i.classList.remove("hide"));
+ } else {
+ [...container.advancedSelect.optionsElements]
+ .filter(i => i.dataset.displayValue.toLowerCase().includes(val))
+ .forEach(i => i.classList.remove("hide"));
+
+ [...container.advancedSelect.optionsElements]
+ .filter(i => !i.dataset.displayValue.toLowerCase().includes(val))
+ .forEach(i => i.classList.add("hide"));
+ }
+
+ autoSetState(container);
+ container.querySelector(".option.focus")?.classList.remove("focus");
+ });
+
+ input.addEventListener("change", () => {
+ container.advancedSelect.dispatchEvent("changed");
+ });
+
+ [...container.advancedSelect.optionsElements].forEach(option => {
+ option.addEventListener("pointerdown", event => {
+ event.preventDefault();
+ selectOption(input, container, event.currentTarget);
+ });
+ });
+
+ return container;
+}
diff --git a/src/js/index.js b/src/js/index.js
index d50c279..9a8ea90 100644
--- a/src/js/index.js
+++ b/src/js/index.js
@@ -10,6 +10,7 @@
import InputPatterns from "./components/input-patterns.js";
import editableString from "./components/editable-string.js";
import Overlays from "./components/overlays.js";
+import advancedSelect from "./components/advanced-select.js";
import Tabs from "./components/tabs.js";
import Progress from "./components/progress.js";
import Tables from "./components/tables.js";
@@ -32,6 +33,7 @@
InputPatterns,
editableString,
Overlays,
+ advancedSelect,
Tabs,
Progress,
Tables,
@@ -66,5 +68,5 @@
});
}
-export { Icons as Helper, Toasts, Modals, confirmPopup, InputPatterns, editableString, Overlays, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell, Background, Breadcrumbs, Accordion, Popover, Loader };
+export { Icons as Helper, Toasts, Modals, confirmPopup, InputPatterns, editableString, Overlays, advancedSelect, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell, Background, Breadcrumbs, Accordion, Popover, Loader };
export default api;
diff --git a/src/scss/components/_advanced-select.scss b/src/scss/components/_advanced-select.scss
new file mode 100644
index 0000000..0bee752
--- /dev/null
+++ b/src/scss/components/_advanced-select.scss
@@ -0,0 +1,85 @@
+/* =========================
+ Advanced select
+========================= */
+
+@use "../kit-deps" as *;
+
+.advanced-select-container {
+ position: relative;
+ display: inline-flex;
+ width: 100%;
+ max-width: 320px;
+}
+
+.advanced-select {
+ position: absolute;
+ z-index: 1000;
+ top: calc(100% + 6px);
+ left: 0;
+ width: 100%;
+ max-height: 200px;
+ overflow-y: auto;
+ background-color: $surface-panel-muted;
+ border: $border-width-base $border-style-base $mint-600;
+ border-left-width: $border-width-accent;
+ border-radius: $radius-md;
+ box-shadow: 0 8px 24px rgba($neutral-900, 0.12);
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(4px);
+ transition:
+ opacity $motion-fast $motion-ease,
+ visibility $motion-fast $motion-ease,
+ transform $motion-fast $motion-ease;
+
+ &.a-show {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+ }
+
+ .popup-options-container {
+ .not-found {
+ width: 100%;
+ padding: $space-4;
+ text-align: center;
+ display: none;
+ color: $neutral-500;
+ font-size: 14px;
+
+ &.show {
+ display: block;
+ }
+ }
+
+ .options {
+ width: 100%;
+ display: none;
+ padding: $space-1 0;
+
+ &.show {
+ display: block;
+ }
+
+ .option {
+ padding: $space-2 $space-4;
+ cursor: pointer;
+ font-size: 14px;
+ color: $neutral-900;
+ transition:
+ color $motion-fast $motion-ease,
+ background-color $motion-fast $motion-ease;
+
+ &.hide {
+ display: none;
+ }
+
+ &:hover,
+ &.focus {
+ color: $neutral-900;
+ background-color: $yellow-300;
+ }
+ }
+ }
+ }
+}
diff --git a/src/scss/kit.scss b/src/scss/kit.scss
index 7f9de21..b639ff5 100644
--- a/src/scss/kit.scss
+++ b/src/scss/kit.scss
@@ -38,6 +38,7 @@
@use "components/skeleton";
@use "components/tooltip";
@use "components/dropdown";
+@use "components/advanced-select";
@use "components/navigation-shell";
@use "utils";