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 @@ +
+

Advanced select

+ +
+ +
+ + +
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";