This commit is contained in:
Nadar
2026-03-17 13:24:22 +03:00
commit 82e5ac9d81
554 changed files with 29637 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import type { InjectionKey } from 'vue'
import type { DropdownContext } from './types'
export const dropdownContextKey: InjectionKey<DropdownContext>
= Symbol('UI_DROPDOWN')

View File

@@ -0,0 +1,187 @@
<template>
<div
ref="rootEl"
:class="[cn.b(), cn.is('active', active), cn.is('focused', focused)]"
>
<UiRenderless ref="triggerEl">
<slot v-bind="{ isActive: active }" />
</UiRenderless>
<Teleport
to="body"
:disabled="!teleport"
>
<div
v-if="withOverlay"
:class="[cn.e('overlay'), cn.is('active', active)]"
/>
<Transition
name="dropdown"
@after-leave="scrollbarInstance()?.destroy()"
>
<div
v-if="active"
ref="popperEl"
:class="[cn.e('popper')]"
:style="floatingStyles"
>
<ul
ref="scrollParentEl"
role="listbox"
:class="[cn.e('dropdown'), dropdownClass]"
>
<slot name="dropdown">
<span>Content</span>
</slot>
</ul>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { provide, ref, toRef, watch } from 'vue'
import {
autoUpdate,
flip as floatingFlip,
offset as floatingOffset,
useFloating,
} from '@floating-ui/vue'
import {
onClickOutside,
useElementHover,
useEventListener,
} from '@vueuse/core'
import type { OffsetOptions, Placement } from '@floating-ui/vue'
import { useOverlayScrollbars } from 'overlayscrollbars-vue'
import useClassname from '../../composables/use-classname.js'
import UiRenderless from '../renderless/index.vue'
import { dropdownContextKey } from './constants'
export interface Props {
trigger?: 'hover' | 'click'
disabled?: false
offset?: OffsetOptions
placement?: Placement
hideOnClick?: boolean
withOverlay?: boolean
dropdownClass?: string
transitionName?: string
teleport?: boolean
}
defineOptions({
name: 'UiDropdown',
})
const props = withDefaults(defineProps<Props>(), {
trigger: 'click',
disabled: false,
placement: 'bottom-start',
offset: 4,
hideOnClick: true,
withOverlay: false,
transitionName: 'ui-dropdown',
teleport: false,
})
defineEmits<{
'update:modelValue': []
focus: []
blur: []
}>()
const rootEl = ref<HTMLElement | null>()
const triggerEl = ref()
const popperEl = ref()
const scrollParentEl = ref()
const active = ref(false)
const focused = ref(false)
const cn = useClassname('ui-dropdown')
if (props.trigger === 'click') {
useEventListener(triggerEl, 'click', () => {
toggle()
})
onClickOutside(
popperEl,
() => {
close()
},
{ ignore: [triggerEl] },
)
}
else {
const isHoveredTrigger = useElementHover(triggerEl, { delayLeave: 300 })
const isHoveredPopper = useElementHover(popperEl, { delayLeave: 10 })
watch(isHoveredTrigger, (value: boolean) => {
if (value)
open()
else if (!isHoveredPopper.value)
close()
})
watch(isHoveredPopper, async (value: boolean) => {
if (!value && !isHoveredTrigger.value)
close()
})
}
const { floatingStyles } = useFloating(triggerEl, popperEl, {
placement: toRef(props, 'placement'),
middleware: [floatingOffset(props.offset), floatingFlip()],
whileElementsMounted: autoUpdate,
})
const [initScrollbar, scrollbarInstance] = useOverlayScrollbars({
options: {
scrollbars: {
theme: 'os-small-theme',
},
},
})
function setActive(state: boolean) {
if (props.disabled)
return
active.value = state
}
function toggle() {
setActive(!active.value)
}
function open() {
setActive(true)
}
function close() {
setActive(false)
}
function handleItemClick() {
if (props.hideOnClick)
close()
}
// watch(scrollParentEl, (value) => {
// if (value)
// initScrollbar(value)
// })
provide(dropdownContextKey, {
handleItemClick,
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,42 @@
<template>
<li
:class="[cn.b(), cn.is('active', active)]"
tabindex="0"
@click="onClick"
>
<div
v-if="$slots.icon"
:class="[cn.e('icon')]"
>
<slot name="icon" />
</div>
<slot />
</li>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import useClassname from '../../composables/use-classname'
import { dropdownContextKey } from './constants'
export interface Props {
active?: boolean
}
defineOptions({
name: 'UiSpinner',
})
const props = withDefaults(defineProps<Props>(), {
active: false,
})
const dropdown = inject(dropdownContextKey)
const cn = useClassname('ui-dropdown-item')
function onClick() {
dropdown.handleItemClick()
}
</script>

View File

@@ -0,0 +1,7 @@
<template>
<li :class="cn.b()" />
</template>
<script setup>
const cn = useClassname('ui-dropdown-separator')
</script>

View File

@@ -0,0 +1,79 @@
@use '../../styles/mixins' as *;
@use '../../styles/variables' as *;
@use 'sass:color';
.ui-dropdown {
$self: &;
position: relative;
display: inline-flex;
&__overlay {
position: fixed;
z-index: 6999;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
transition: background-color .2s ease-out;
&.is-active {
background-color: color.change($clr-black, $alpha: 0.15);
}
}
&__popper {
min-width: 170px;
z-index: 7000;
}
&__dropdown {
//overflow: hidden;
border-radius: 12px;
background-color: $clr-white;
list-style: none;
padding: 0;
margin: 0;
&::-webkit-scrollbar {
display: none;
}
}
}
.ui-dropdown-item {
@include txt-m-m('dropdown-item');
display: flex;
align-items: center;
padding: var(--dropdown-item-padding, 12px 16px);
background-color: $clr-white;
transition: background-color .2s ease-out;
cursor: pointer;
user-select: none;
border-radius: 8px;
outline: none;
&:hover,
&:focus {
background-color: $clr-grey-200;
}
&:active,
&.is-active {
background-color: $clr-grey-300;
}
&__icon {
margin-right: 8px;
}
}
.ui-dropdown-separator {
height: 1px;
width: 100%;
background-color: $clr-grey-200;
margin-block: 12px;
}

View File

@@ -0,0 +1,3 @@
export interface DropdownContext {
handleItemClick: () => void
}