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 { AccordionContext } from './types'
export const accordionContextKey: InjectionKey<AccordionContext>
= Symbol('UI_ACCORDION')

View File

@@ -0,0 +1,92 @@
<template>
<div :class="[cn.b(), cn.is('disabled', disabled)]">
<slot />
</div>
</template>
<script setup lang="ts">
import { provide, ref, watch } from 'vue'
import { castArray } from 'lodash-es'
import useClassname from '../../composables/use-classname'
import type { AccordionActiveId, AccordionModelValue } from './types'
import { accordionContextKey } from './constants'
import type { UiIcon } from '#build/types/ui/icons'
export interface Props {
multiple?: boolean
modelValue?: AccordionModelValue
disabled?: boolean
inactiveIcon?: UiIcon
activeIcon?: UiIcon
}
defineOptions({
name: 'UiAccordion',
})
const props = withDefaults(defineProps<Props>(), {
multiple: false,
disabled: false,
inactiveIcon: 'plus',
activeIcon: 'minus',
})
const emit = defineEmits<{
'update:modelValue': [value: Props['modelValue']]
}>()
const cn = useClassname('ui-accordion')
const activeItems = ref(castArray(props.modelValue))
function setActiveItems(_activeItems: AccordionActiveId[]) {
activeItems.value = _activeItems
const value = !props.multiple ? activeItems.value[0] : activeItems.value
emit('update:modelValue', value)
}
function handleItemClick(id: AccordionActiveId) {
if (props.disabled)
return
if (!props.multiple) {
setActiveItems([activeItems.value[0] === id ? '' : id])
}
else {
const _activeItems = [...activeItems.value]
const index = _activeItems.indexOf(id)
if (index > -1)
_activeItems.splice(index, 1)
else
_activeItems.push(id)
setActiveItems(_activeItems)
}
}
function isActive(id: AccordionActiveId) {
return activeItems.value.includes(id)
}
watch(
() => props.modelValue,
value => (activeItems.value = castArray(value)),
{ deep: true },
)
provide(accordionContextKey, {
activeItems,
handleItemClick,
isActive,
inactiveIcon: toRef(props, 'inactiveIcon'),
activeIcon: toRef(props, 'activeIcon'),
disabled: toRef(props, 'disabled'),
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div
:class="[
cn.b(),
cn.is('focused', focused),
cn.is('active', isActive),
cn.is('disabled', disabled),
]"
>
<button
:class="[cn.e('head')]"
type="button"
@focus="focused = true"
@blur="focused = false"
@click="handleClick"
@keydown.space.enter.stop.prevent="handleClick"
>
<slot name="title">
<h3 :class="[cn.e('title')]">
{{ title }}
</h3>
</slot>
<Component
:is="resolveComponent(`ui-icon-${isActive ? activeIcon : inactiveIcon}`)"
v-if="!disabled"
:class="cn.e('icon')"
/>
</button>
<UiAccordionTransition>
<div
v-show="isActive"
:class="[cn.e('wrapper')]"
>
<div :class="[cn.e('content')]">
<slot />
</div>
</div>
</UiAccordionTransition>
</div>
</template>
<script setup lang="ts">
import { uniqueId } from 'lodash-es'
import { computed, inject, ref } from 'vue'
import useClassname from '../../composables/use-classname'
import UiAccordionTransition from './transition.vue'
import { accordionContextKey } from './constants'
import type { UiIcon } from '#build/types/ui/icons'
export interface Props {
id?: string | number
title?: string
}
defineOptions({
name: 'UiAccordionItem',
})
const props = withDefaults(defineProps<Props>(), {
id: () => uniqueId(),
title: '',
})
const accordion = inject(accordionContextKey)
const cn = useClassname('ui-accordion-item')
const focused = ref(false)
const isActive = computed(() => accordion?.isActive(props.id) ?? false)
const activeIcon = computed<UiIcon>(() => accordion?.activeIcon.value ?? 'minus')
const inactiveIcon = computed<UiIcon>(() => accordion?.inactiveIcon.value ?? 'plus')
const disabled = computed<boolean>(() => accordion?.disabled.value ?? false)
function handleClick() {
accordion?.handleItemClick(props.id)
}
</script>

View File

@@ -0,0 +1,96 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.ui-accordion {
display: flex;
flex-direction: column;
gap: 16px;
}
.ui-accordion-item {
$self: &;
--border-color: var(--accordion-item-border-color);
--border-width: 1px;
outline: var(--border-width) solid var(--border-color);
outline-offset: calc(var(--border-width) * -1);
border-radius: 12px;
background: $clr-white;
transition: .2s ease-out;
transition-property: outline-color, background-color, color;
-webkit-tap-highlight-color: transparent;
&:hover {
--border-width: 2px;
}
&.is-focused {
--border-width: 2px;
}
&.is-active {
--border-width: 2px;
--border-color: #{$clr-grey-500};
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
text-align: left;
border: none;
padding: var(--accordion-head-padding, 24px);
background: transparent;
transition: padding .2s ease-in-out;
cursor: pointer;
outline: none;
user-select: none;
#{$self}.is-active & {
padding: var(--accordion-head-active-padding, 24px 24px 4.5px);
}
#{$self}.is-disabled & {
cursor: default;
}
}
&__title {
color: $clr-grey-600;
}
&__icon {
color: $clr-grey-600;
}
&__wrapper {
will-change: height;
overflow: hidden;
}
&__content {
@include txt-i;
color: $clr-grey-600;
padding: var(--accordion-content-padding, 0 24px 24px);
}
@include element-variant('accordion-item', '', (
'border-color': $clr-grey-300,
))
}
.ui-accordion-transition {
transition: .2s height ease-in-out,
.2s padding-top ease-in-out,
.2s padding-bottom ease-in-out;
}
.ui-accordion-transition-leave-active,
.ui-accordion-transition-enter-active {
transition: .2s max-height ease-in-out,
.2s padding-top ease-in-out,
.2s padding-bottom ease-in-out;
}

View File

@@ -0,0 +1,85 @@
<template>
<Transition :name="ns.b()" v-on="on">
<slot />
</Transition>
</template>
<script lang="ts" setup>
import type { RendererElement } from 'vue'
import useNamespace from '../../composables/use-classname'
defineOptions({
name: 'UiAccordionTransition',
})
const ns = useNamespace('ui-accordion-transition')
function reset(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
}
const on = {
beforeEnter(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
},
enter(el: RendererElement) {
el.dataset.oldOverflow = el.style.overflow
if (el.scrollHeight !== 0)
el.style.maxHeight = `${el.scrollHeight}px`
else
el.style.maxHeight = 0
el.style.paddingTop = el.dataset.oldPaddingTop
el.style.paddingBottom = el.dataset.oldPaddingBottom
el.style.overflow = 'hidden'
},
afterEnter(el: RendererElement) {
el.style.maxHeight = ''
el.style.overflow = el.dataset.oldOverflow
},
enterCancelled(el: RendererElement) {
reset(el)
},
beforeLeave(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.oldOverflow = el.style.overflow
el.style.maxHeight = `${el.scrollHeight}px`
el.style.overflow = 'hidden'
},
leave(el: RendererElement) {
if (el.scrollHeight !== 0) {
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
}
},
afterLeave(el: RendererElement) {
reset(el)
},
leaveCancelled(el: RendererElement) {
reset(el)
},
}
</script>

View File

@@ -0,0 +1,14 @@
import type { Ref } from 'vue'
import type { UiIcon } from '#build/types/ui/icons'
export type AccordionActiveId = string | number
export type AccordionModelValue = AccordionActiveId | AccordionActiveId[]
export interface AccordionContext {
activeItems: Ref<AccordionActiveId[]>
handleItemClick: (id: AccordionActiveId) => void
isActive: (id: AccordionActiveId) => boolean
inactiveIcon: Ref<UiIcon>
activeIcon: Ref<UiIcon>
disabled: Ref<boolean>
}