Skip to content

Instantly share code, notes, and snippets.

@distancify-oscar
Last active November 10, 2023 17:36
Show Gist options
  • Save distancify-oscar/df1f93caf54715ecb986c3deb8636407 to your computer and use it in GitHub Desktop.
Save distancify-oscar/df1f93caf54715ecb986c3deb8636407 to your computer and use it in GitHub Desktop.
Useful Components
Display the source blob
Display the rendered blob
Raw
<svg width="18" height="11" viewBox="0 0 18 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0.5" y1="-0.5" x2="11.7518" y2="-0.5" transform="matrix(0.707114 0.7071 -0.707113 0.7071 8.33691 1.38611)" stroke="white" stroke-linecap="round"/>
<line x1="0.5" y1="-0.5" x2="11.7518" y2="-0.5" transform="matrix(-0.707113 0.7071 -0.707114 -0.7071 9.01709 0.666626)" stroke="white" stroke-linecap="round"/>
</svg>
<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>
<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>
<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