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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
<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