Last active
November 10, 2023 17:36
-
-
Save distancify-oscar/df1f93caf54715ecb986c3deb8636407 to your computer and use it in GitHub Desktop.
Useful Components
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script setup lang="ts"> | |
| import { ref, computed } from 'vue'; | |
| import StandardIcon from '../buttons/StandardIcon.vue'; | |
| interface Props { | |
| options: DropdownOption[]; | |
| modelValue: string; | |
| } | |
| interface DropdownOption { | |
| value: string; | |
| label: string; | |
| } | |
| const props = defineProps<Props>(); | |
| const emits = defineEmits(['update:modelValue']) | |
| let timer: any; | |
| const searchBuffer = ref(''); | |
| const lastInput = ref(''); | |
| const searchResult = ref<DropdownOption[]>([]); | |
| const isOpen = ref(false); | |
| const dropdownEl = ref<HTMLDivElement|null>(null); | |
| const selectedOption = computed(() => { | |
| return props.options.find(o => o.value == props.modelValue) || props.options[0]; | |
| }) | |
| function selectOption(value: string) { | |
| emits('update:modelValue', value) | |
| } | |
| function selectAndCloseDropdown(value: string) { | |
| if (props.modelValue != value) { | |
| selectOption(value) | |
| } | |
| close(); | |
| } | |
| function toggle() { | |
| if (!isOpen.value) { | |
| open(); | |
| } else { | |
| close(); | |
| } | |
| } | |
| function closeOnOutsideClick(ev: Event) { | |
| if (!dropdownEl.value?.contains(ev.target as Element)) { | |
| close(); | |
| } | |
| } | |
| function open() { | |
| isOpen.value = true; | |
| document.body.addEventListener('click', closeOnOutsideClick); | |
| } | |
| function close() { | |
| isOpen.value = false; | |
| document.body.removeEventListener('click', closeOnOutsideClick) | |
| } | |
| function selectNext() { | |
| const currentIdx = props.options.indexOf(selectedOption.value); | |
| if (currentIdx + 1 < props.options.length) { | |
| selectOption(props.options[currentIdx + 1].value); | |
| } | |
| } | |
| function selectPrev() { | |
| const currentIdx = props.options.indexOf(selectedOption.value); | |
| if (currentIdx - 1 >= 0) { | |
| selectOption(props.options[currentIdx - 1].value); | |
| } | |
| } | |
| function performKeyboardAction(e: KeyboardEvent) { | |
| switch (e.code) { | |
| case 'Escape': | |
| close(); | |
| break; | |
| case 'Space': | |
| open(); | |
| break; | |
| case 'Enter': | |
| toggle(); | |
| break; | |
| case 'ArrowUp': | |
| selectPrev(); | |
| break; | |
| case 'ArrowDown': | |
| selectNext(); | |
| break; | |
| default: | |
| if (e.key.length === 1) { | |
| beginSearch(e.key); | |
| } | |
| break; | |
| } | |
| } | |
| function beginSearch(key: string) { | |
| if (timer) { | |
| clearTimeout(timer); | |
| } | |
| if (lastInput.value === key && searchResult.value.length) { | |
| selectNextResult(); | |
| return; | |
| } | |
| searchBuffer.value += key; | |
| timer = setTimeout(() => { | |
| executeSearch(searchBuffer.value); | |
| searchBuffer.value = ''; | |
| }, 200); | |
| lastInput.value = key; | |
| }; | |
| function executeSearch(query: string) { | |
| const matchedOptions = props.options.filter(o => o.label.toLowerCase().includes(query)); | |
| if (!matchedOptions.length) return; | |
| searchResult.value = matchedOptions.reduce((acc, curr) => { | |
| const position = curr.label.toLowerCase().indexOf(query); | |
| if (0 == position) { | |
| acc.push(curr); | |
| } | |
| return acc; | |
| }, [] as DropdownOption[]); | |
| if (searchResult.value.length) { | |
| selectNextResult(); | |
| } | |
| } | |
| function selectNextResult() { | |
| const selected = searchResult.value.find(s => s.value === props.modelValue); | |
| if (!selected) { | |
| selectOption(searchResult.value[0].value); | |
| return; | |
| } | |
| if (searchResult.value.length === 1) { | |
| return; | |
| } | |
| const currentIdx = searchResult.value.indexOf(selected); | |
| const nextIndex = (currentIdx + 1) % searchResult.value.length; | |
| selectOption(searchResult.value[nextIndex].value); | |
| } | |
| </script> | |
| <template> | |
| <div | |
| ref="dropdownEl" | |
| class="standard-dropdown" | |
| :class="[{'standard-dropdown--open': isOpen}]" | |
| > | |
| <div | |
| class="standard-dropdown__selected" | |
| tabindex="0" | |
| @click="toggle" | |
| @keydown.prevent="performKeyboardAction" | |
| > | |
| {{ selectedOption?.label }} | |
| <StandardIcon | |
| @click="toggle" | |
| class="standard-dropdown__selected-icon" | |
| icon="arrow" | |
| /> | |
| </div> | |
| <Transition> | |
| <ul | |
| class="standard-dropdown__item-list" | |
| v-if="isOpen" | |
| > | |
| <li | |
| class="standard-dropdown__item" | |
| :class="{'standard-dropdown__item--selected': option.value === selectedOption?.value}" | |
| v-for="option in options" | |
| :key="option.value" | |
| @click="selectAndCloseDropdown(option.value)" | |
| > | |
| {{ option.label }} | |
| </li> | |
| </ul> | |
| </Transition> | |
| </div> | |
| </template> | |
| <style> | |
| .standard-dropdown { | |
| position: relative; | |
| user-select: none; | |
| } | |
| .standard-dropdown__selected { | |
| background-color: var(--color-neutrals-90); | |
| color: var(--color-text-white); | |
| border: 1px solid var(--color-neutrals-50); | |
| padding: 8.5px 14px; | |
| border-radius: 4px; | |
| font-size: 14px; | |
| line-height: 18px; | |
| font-weight: 400; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| cursor: pointer; | |
| gap: 20px; | |
| } | |
| .standard-dropdown--open .standard-dropdown__selected { | |
| border-radius: 4px 4px 0px 0px; | |
| } | |
| .standard-dropdown__selected-icon { | |
| transition: transform 0.2s ease-in-out; | |
| height: 10px; | |
| } | |
| .standard-dropdown--open .standard-dropdown__selected-icon { | |
| transform: rotate(-180deg) translateY(-2px); | |
| } | |
| .standard-dropdown__item-list { | |
| position: absolute; | |
| cursor: pointer; | |
| background-color: #262626; | |
| margin: 0; | |
| list-style-type: none; | |
| width: 100%; | |
| padding: 0px; | |
| z-index: 1; | |
| box-shadow: var(--shadow-outer); | |
| border-radius: 0px 0px 4px 4px; | |
| overflow: hidden; | |
| } | |
| .standard-dropdown__item { | |
| padding: 8px 14px; | |
| border-bottom: 1px solid var(--color-neutrals-90); | |
| } | |
| .standard-dropdown__item:last-child { | |
| border: none; | |
| } | |
| .standard-dropdown__item:hover { | |
| background-color: var(--color-neutrals-50); | |
| } | |
| .standard-dropdown__item--selected { | |
| background-color: var(--color-neutrals-60); | |
| } | |
| .v-enter-active, | |
| .v-leave-active { | |
| transition: opacity 0.2s ease; | |
| } | |
| .v-enter-from, | |
| .v-leave-to { | |
| opacity: 0; | |
| } | |
| </style> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script> | |
| export default { | |
| data: () => ({ | |
| isVisible: false, | |
| }), | |
| methods: { | |
| scrollToTop() { | |
| document.body.scrollTop = 0; | |
| document.documentElement.scrollTop = 0; | |
| }, | |
| }, | |
| mounted() { | |
| const options = { | |
| rootMargin: "200px 0px 0px 0px", | |
| }; | |
| this.scrollToTopObserver = new IntersectionObserver((entries) => { | |
| const button = document.querySelector("#top-scroll-button"); | |
| entries.forEach((entry) => { | |
| if (button) { | |
| this.isVisible = !entry.isIntersecting && !this.isVisible; | |
| button.style.opacity = this.isVisible ? 1 : 0; | |
| button.style.pointerEvents = this.isVisible ? 'auto' : 'none'; | |
| } | |
| }); | |
| }, options); | |
| const target = document.querySelector("#top-marker"); | |
| if (target) { | |
| this.scrollToTopObserver.observe(target); | |
| } | |
| }, | |
| beforeUnmount() { | |
| this.scrollToTopObserver?.disconnect(); | |
| }, | |
| }; | |
| </script> | |
| <template> | |
| <teleport to="body"> | |
| <div class="scroll-to-top__top-marker" id="top-marker"></div> | |
| </teleport> | |
| <div class="scroll-to-top__wrapper"> | |
| <div | |
| class="scroll-to-top__button" | |
| id="top-scroll-button" | |
| @click="scrollToTop" | |
| > | |
| <img | |
| class="scroll-to-top__button-icon" | |
| src="../static/icons/chevron_up_white_icon.svg" | |
| > | |
| </div> | |
| </div> | |
| </template> | |
| <style> | |
| .scroll-to-top__top-marker { | |
| pointer-events: none; | |
| position: absolute; | |
| top: 0; | |
| } | |
| .scroll-to-top__wrapper { | |
| max-width: 1234px; | |
| z-index: 50; | |
| width: 100%; | |
| margin: auto; | |
| position: fixed; | |
| bottom: 10%; | |
| left: 0; | |
| right: 0; | |
| padding: 0 10px; | |
| } | |
| .scroll-to-top__button { | |
| margin-left: auto; | |
| display: grid; | |
| place-items: center; | |
| height: 40px; | |
| width: 40px; | |
| background: var(--color-neutrals-100); | |
| color: var(--color-neutrals-00); | |
| box-shadow: var(--shadow-outer); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| transition: opacity 0.2s linear; | |
| } | |
| .scroll-to-top__button-icon { | |
| width: 16px; | |
| } | |
| </style> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script setup lang="ts"> | |
| import { ref, onMounted, onBeforeUnmount } from 'vue'; | |
| const scrollWidth = 500; | |
| const leftObserver = ref(null); | |
| const rightObserver = ref(null); | |
| const canScrollLeft = ref(false); | |
| const canScrollRight = ref(false); | |
| const allFacets = ref(null); | |
| function scrollLeft() { | |
| allFacets.value.scrollBy(-scrollWidth, 0) | |
| } | |
| function scrollRight() { | |
| allFacets.value.scrollBy(scrollWidth, 0) | |
| } | |
| function initAllFacetsArrows() { | |
| const root = allFacets.value; | |
| if (!root) return | |
| if (root.scrollWidth <= root.clientWidth) { | |
| return | |
| } | |
| leftObserver.value = createObserver( | |
| root, | |
| (entry) => (canScrollLeft.value = !entry.isIntersecting), | |
| ) | |
| const firstElement = allFacets.value.children[0]; | |
| if (firstElement) { | |
| leftObserver.value.observe(firstElement); | |
| } | |
| rightObserver.value = createObserver( | |
| root, | |
| (entry) => (canScrollRight.value = !entry.isIntersecting), | |
| ) | |
| const lastElement = allFacets.value.children[allFacets.value.children.length - 1]; | |
| if (lastElement) { | |
| rightObserver.value.observe(lastElement); | |
| } | |
| } | |
| function createObserver(root, cb) { | |
| return new IntersectionObserver( | |
| (entries, observer) => { | |
| entries.forEach((entry) => { | |
| cb(entry) | |
| }) | |
| }, | |
| { | |
| root, | |
| rootMargin: '0px', | |
| threshold: 1.0, | |
| }, | |
| ) | |
| } | |
| function removeAllFacetsArrows() { | |
| leftObserver.value?.disconnect() | |
| leftObserver.value = null | |
| rightObserver.value?.disconnect() | |
| rightObserver.value = null | |
| } | |
| onMounted(() => { | |
| initAllFacetsArrows() | |
| }) | |
| onBeforeUnmount(() => { | |
| removeAllFacetsArrows() | |
| }) | |
| </script> | |
| <template> | |
| <div class="listing-facets"> | |
| <div class="listing-facets__available-wrapper"> | |
| <div class="listing-facets__available" ref="allFacets"> | |
| <!-- items here --> | |
| </div> | |
| <div | |
| class="listing-facets__available-scroll-left" | |
| v-if="canScrollLeft" | |
| @click="scrollLeft()" | |
| > | |
| <img | |
| class="listing-facets__available-scroll-left-icon" | |
| /> | |
| </div> | |
| <div | |
| class="listing-facets__available-scroll-right" | |
| v-if="canScrollRight" | |
| @click="scrollRight()" | |
| > | |
| <img | |
| class="listing-facets__available-scroll-right-icon" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <style> | |
| .listing-facets { | |
| display: flex; | |
| font-size: 14px; | |
| padding: 0 0 0 10px; | |
| } | |
| .listing-facets__available-wrapper { | |
| overflow: hidden; | |
| position: relative; | |
| padding: 5px 15px 20px; | |
| } | |
| .listing-facets__available { | |
| display: flex; | |
| overflow-x: auto; | |
| margin: 0 -15px; | |
| padding: 8px 15px; | |
| scroll-behavior: smooth; | |
| scrollbar-width: none; | |
| -ms-overflow-style: none; | |
| } | |
| .listing-facets__available::-webkit-scrollbar { | |
| display: none; | |
| } | |
| .listing-facets__available-scroll-left { | |
| left: 0px; | |
| } | |
| .listing-facets__available-scroll-left::after { | |
| content: ''; | |
| position: absolute; | |
| height: 100%; | |
| width: 35px; | |
| left: -15px; | |
| top: 0; | |
| background-color: white; | |
| z-index: -1; | |
| } | |
| .listing-facets__available-scroll-right { | |
| right: 0px; | |
| } | |
| .listing-facets__available-scroll-right::after { | |
| content: ''; | |
| position: absolute; | |
| height: 100%; | |
| width: 35px; | |
| top: 0; | |
| right: -15px; | |
| background-color: white; | |
| z-index: -1; | |
| } | |
| .listing-facets__available-scroll-left, | |
| .listing-facets__available-scroll-right { | |
| display: block; | |
| position: absolute; | |
| top: 4px; | |
| height: 54px; | |
| width: 54px; | |
| background-color: var(--color-neutrals-00); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| border-collapse: collapse; | |
| box-shadow: 3px 3px 16px rgba(26, 26, 26, 0.12); | |
| } | |
| .listing-facets__available-scroll-right-icon, | |
| .listing-facets__available-scroll-left-icon { | |
| position: absolute; | |
| top: 18px; | |
| width: 18px; | |
| height: 18px; | |
| left: 18px; | |
| } | |
| .listing-facets__available-scroll-right-icon { | |
| transform: rotate(270deg); | |
| } | |
| .listing-facets__available-scroll-left-icon { | |
| transform: rotate(90deg); | |
| } | |
| @media (--phone) { | |
| .listing-facets__available-scroll-left, | |
| .listing-facets__available-scroll-right { | |
| display: none; | |
| } | |
| } | |
| </style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment