Initial commit

This commit is contained in:
Никита Круглицкий
2024-11-26 16:15:12 +03:00
commit b3c99a667a
97 changed files with 14314 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
import type { InjectionKey } from 'vue'
import type { AccordionContext } from './types'
export const accordionContextKey: InjectionKey<AccordionContext> = Symbol('UI_ACCORDION')

View File

@@ -0,0 +1,78 @@
<template>
<div :class="[cn.b()]">
<slot />
</div>
</template>
<script setup lang="ts">
import type { AccordionActiveId, AccordionModelValue } from './types'
import { castArray } from 'lodash-es'
import { provide, ref, watch } from 'vue'
import { accordionContextKey } from './constants'
export interface Props {
multiple?: boolean
modelValue?: AccordionModelValue
}
defineOptions({
name: 'UiAccordion',
})
const props = withDefaults(defineProps<Props>(), {
multiple: false,
})
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.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,
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div :class="[cn.b(), cn.is('focused', focused), cn.is('active', isActive)]">
<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="isActive ? IMonoMinus : IMonoPlus" :class="cn.e('icon')" />
</button>
<UiCollapseTransition>
<div
v-show="isActive"
:class="[cn.e('wrapper')]"
>
<div :class="[cn.e('content')]">
<slot />
</div>
</div>
</UiCollapseTransition>
</div>
</template>
<script setup lang="ts">
import IMonoMinus from '~icons/mono/minus'
import IMonoPlus from '~icons/mono/plus'
import { computed, inject, ref } from 'vue'
import UiCollapseTransition from '~/components/ui/collapse-transition/index.vue'
import { accordionContextKey } from './constants'
export interface Props {
id?: string | number
title?: string
}
defineOptions({
name: 'UiAccordionItem',
})
const props = withDefaults(defineProps<Props>(), {
id: () => useId(),
title: '',
})
const accordion = inject(accordionContextKey)
const cn = useClassname('ui-accordion-item')
const focused = ref(false)
const isActive = computed(() => accordion?.isActive(props.id) ?? false)
function handleClick() {
accordion?.handleItemClick(props.id)
}
</script>

View File

@@ -0,0 +1,89 @@
@use '../../../styles/variables' as *;
@use '../../../styles/mixins' as *;
.ui-accordion {
display: flex;
flex-direction: column;
gap: 16px;
}
.ui-accordion-item {
$self: &;
border-radius: 12px;
background-color: $color-white;
transition: outline-color $transition-duration $transition-easing;
-webkit-tap-highlight-color: transparent;
outline: 1px solid $color-gray-500;
outline-offset: -1px;
&:focus,
&:hover,
&.is-active {
outline-width: 2px;
outline-offset: -2px;
}
&.is-active {
outline-color: $color-gray-600;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
text-align: left;
border: none;
padding: 24px;
background: transparent;
transition: padding $transition-duration $transition-easing;
cursor: pointer;
outline: none;
user-select: none;
color: $color-black;
@include mobile {
padding: 16px;
}
#{$self}.is-active & {
padding: 24px 24px 4px;
@include mobile {
padding: 16px 16px 4px;
}
}
}
&__title {
@include font(18px, 600, 23px);
@include mobile {
@include font(13px, 600, 17px);
}
}
&__icon {
flex-shrink: 0;
font-size: 24px;
color: $color-gray-600;
}
&__wrapper {
will-change: height;
overflow: hidden;
}
&__content {
@include font(14px, 400, 18px);
color: $color-gray-700;
padding: 0 24px 24px;
@include mobile {
padding: 0 16px 16px;
}
}
}

View File

@@ -0,0 +1,10 @@
import type { Ref } from 'vue'
export type AccordionActiveId = string | number
export type AccordionModelValue = AccordionActiveId | AccordionActiveId[]
export interface AccordionContext {
activeItems: Ref<AccordionActiveId[]>
handleItemClick: (id: AccordionActiveId) => void
isActive: (id: AccordionActiveId) => boolean
}

View File

@@ -0,0 +1,105 @@
<template>
<Component
:is="as"
:class="[
cn.b(),
cn.m(type),
cn.is('one-icon-only', oneIconOnly),
cn.has('icon', hasIcon),
cn.is('disabled', disabled),
]"
:disabled="disabled"
v-bind="attributes"
@click="handleClick"
>
<span
v-if="hasLeftIcon && displayIcon"
:class="[cn.e('icon'), cn.em('icon', 'left')]"
>
<slot name="left-icon" />
</span>
<span
v-if="!iconOnly"
:class="[cn.e('content')]"
><slot /></span>
<span
v-if="hasRightIcon && displayIcon"
:class="[cn.e('icon'), cn.em('icon', 'right')]"
>
<slot name="right-icon" />
</span>
</Component>
</template>
<script setup lang="ts">
import type { ButtonHTMLAttributes } from 'vue'
export interface Props {
type?: 'primary' | 'secondary'
nativeType?: ButtonHTMLAttributes['type']
disabled?: boolean
}
defineOptions({
name: 'UiButton',
})
const props = withDefaults(defineProps<Props>(), {
type: 'primary',
nativeType: 'button',
disabled: false,
})
const emit = defineEmits<{
click: [e: Event]
}>()
const slots = useSlots()
const attrs = useAttrs()
const cn = useClassname('ui-button')
const as = computed(() => (attrs.href ? 'a' : 'button'))
const attributes = computed(() => {
if (as.value === 'button') {
return {
type: props.nativeType,
...attrs,
}
}
return attrs
})
const hasLeftIcon = computed(() => !!slots['left-icon'])
const hasRightIcon = computed(() => !!slots['right-icon'])
const hasIcon = computed(() => hasLeftIcon.value || hasRightIcon.value)
const oneIconOnly = computed(
() =>
((hasLeftIcon.value && !hasRightIcon.value)
|| (!hasLeftIcon.value && hasRightIcon.value))
&& !slots.default,
)
const iconOnly = computed(
() => hasIcon.value && !slots.default,
)
const displayIcon = computed(() => hasIcon.value)
function handleClick(event: Event) {
if (props.disabled) {
event.stopPropagation()
event.preventDefault()
}
else {
emit('click', event)
}
}
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,113 @@
@use "sass:list";
@use '../../../styles/mixins' as *;
@use '../../../styles/variables' as *;
$button-types: 'filled', 'outlined', 'ghost', 'link';
$button-colors: 'primary', 'secondary';
/* prettier-ignore */
$button-variants: [
[
'primary',
(
'color': $color-white,
'background': $color-green-500,
'hover-background': $color-green-400,
'active-background': $color-gray-600,
)
],
[
'secondary',
(
'color': $color-green-500,
'background': $color-gray-300,
'hover-background': $color-gray-400,
'active-background': $color-gray-500,
)
]
];
.ui-button {
$self: &;
@include font(16px, 500, 20px);
--spinner-size: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
position: relative;
cursor: pointer;
outline: none;
padding: 13px 23px;
background: var(--button-background, transparent);
color: var(--button-color, inherit);
border-radius: 12px;
width: var(--button-width, unset);
height: var(--button-height, 48px);
border: 1px solid var(--button-border-color, transparent);
white-space: nowrap;
transition: $transition-duration $transition-easing;
transition-property: background-color, border-color, color, opacity;
&:visited {
color: var(--button-color, inherit);
}
&:hover,
&:focus-visible {
--button-icon-color: var(--button-hover-color, var(--button-color));
background: var(--button-hover-background, var(--button-background));
color: var(--button-hover-color, var(--button-color));
border-color: var(--button-hover-border-color, transparent);
}
&:active {
--button-icon-color: var(--button-active-color, var(--button-color));
background: var(--button-active-background, var(--button-background));
color: var(--button-active-color, var(--button-color));
border-color: var(--button-active-border-color, transparent);
}
&.is-disabled {
background: var(--button-disabled-background, transparent);
color: var(--button-disabled-color);
border-color: var(--button-disabled-border-color, transparent);
cursor: not-allowed;
}
&__icon {
color: var(--button-icon-color, inherit);
line-height: 1;
transition: color $transition-duration $transition-easing;
#{$self}.is-one-icon-only:not(#{$self}--link) & {
color: inherit;
}
}
&.is-one-icon-only {
padding: 0;
width: var(--button-width, var(--button-height, 40px));
}
@each $variant in $button-variants {
$color: list.nth($variant, 1);
$scheme: list.nth($variant, 2);
&--#{$color} {
@each $property, $value in $scheme {
--button-#{$property}: var(--button-#{$color}-#{$property});
}
}
/* prettier-ignore */
@include element-variant('button', $color, $scheme);
}
}

View File

@@ -0,0 +1,103 @@
<template>
<Transition :name="ns.b()" v-on="bindings">
<slot />
</Transition>
</template>
<script lang="ts" setup>
import type { RendererElement } from 'vue'
defineOptions({
name: 'UiCollapseTransition',
})
const emit = defineEmits<{
expanded: []
collapsed: []
}>()
const ns = useClassname('ui-collapse-transition')
const bindings = {
beforeEnter(el: RendererElement) {
if (!el.dataset)
el.dataset = {}
el.dataset.oldPaddingTop = el.style.paddingTop
el.dataset.oldPaddingBottom = el.style.paddingBottom
el.dataset.elExistsHeight = el.style.height ?? undefined
el.style.maxHeight = 0
el.style.paddingTop = 0
el.style.paddingBottom = 0
},
enter(el: RendererElement) {
requestAnimationFrame(() => {
el.dataset.oldOverflow = el.style.overflow
if (el.dataset.elExistsHeight)
el.style.maxHeight = el.dataset.elExistsHeight
else 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
emit('expanded')
},
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)
emit('collapsed')
},
leaveCancelled(el: RendererElement) {
reset(el)
},
}
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
}
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,24 @@
@use '../../../styles/variables' as *;
.ui-collapse-transition {
transition-property: height, padding-top, padding-bottom;
transition-timing-function: var(--collapse-transition-easing, $transition-easing);
transition-duration: var(--collapse-transition-duration, $transition-duration);
}
.ui-collapse-transition-leave-active,
.ui-collapse-transition-enter-active {
transition-property: max-height, padding-top, padding-bottom, opacity;
transition-timing-function: var(--collapse-transition-easing, $transition-easing);
transition-duration: var(--collapse-transition-duration, $transition-duration);
}
.ui-collapse-transition-enter-to,
.ui-collapse-transition-leave-from {
opacity: 1;
}
.ui-collapse-transition-enter-from,
.ui-collapse-transition-leave-to {
opacity: 0;
}

View File

@@ -0,0 +1,128 @@
<template>
<Transition
:name="cn.b()"
@before-leave="$emit('close')"
@after-leave="$emit('destroy')"
>
<UiAlert
v-show="visible"
:id="id"
:class="[cn.b(), verticalSide, horizontalSide]"
:title="title"
:type="type"
@mouseenter="clearTimer"
@mouseleave="startTimer"
>
<slot />
</UiAlert>
</Transition>
</template>
<script setup lang="ts">
import type { NotificationPlacement, NotificationType } from './types'
import { computed, onMounted, ref } from 'vue'
export interface Props {
duration?: number
id?: string | number
offset?: number
placement?: NotificationPlacement
type?: NotificationType
title?: string
zIndex?: number
}
defineOptions({
name: 'UiNotification',
})
const props = withDefaults(defineProps<Props>(), {
type: 'neutral',
duration: 5000,
offset: 0,
placement: 'top-right',
zIndex: 0,
})
defineEmits(['close', 'destroy'])
const cn = useClassname('ui-notification')
const timerId = ref()
const visible = ref(false)
const verticalSide = computed(() => props.placement.split('-')[0])
const horizontalSide = computed(() => props.placement.split('-')[1])
function close() {
visible.value = false
}
function startTimer() {
if (props.duration > 0) {
timerId.value = setTimeout(() => {
if (visible.value)
close()
}, props.duration)
}
}
function clearTimer() {
if (timerId.value)
clearTimeout(timerId.value)
}
onMounted(() => {
startTimer()
visible.value = true
})
defineExpose({
visible,
close,
})
</script>
<style lang="scss">
.ui-notification {
position: fixed;
transition-duration: $transition-duration;
transition-property: opacity, transform, left, right, top, bottom;
z-index: calc(8000 + v-bind('zIndex'));
width: 400px;
transform-origin: right top;
@include mobile {
width: calc(100% - 32px);
}
&.top {
top: calc(v-bind('`${offset}px`') + 80px);
@include mobile {
top: auto;
bottom: v-bind('`${offset}px`');
}
}
&.bottom {
bottom: v-bind('`${offset}px`');
}
&.left {
left: 32px;
@include mobile {
left: 16px;
}
}
&.right {
right: 32px;
@include mobile {
right: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,132 @@
import { createVNode, render } from 'vue'
import { defu } from 'defu'
import NotificationConstructor from './notification.vue'
import type {
NotificationOptions,
NotificationPlacement,
NotificationQueue,
} from './types'
const notifications: Record<NotificationPlacement, NotificationQueue> = {
'top-left': [],
'top-right': [],
'bottom-left': [],
'bottom-right': [],
}
const GAP_SIZE = 16
let SEED = 1
const DEFAULT_OPTIONS: NotificationOptions = {
text: '',
placement: 'top-right',
duration: 5000,
onClose: () => {},
}
const notify = function (options: NotificationOptions, context = null) {
// if (process.server) return { close: () => undefined };
options = defu(options, DEFAULT_OPTIONS)
const orientedNotifications = notifications[options.placement!]
const id = options.id
? `${options.placement!}_${options.id}`
: `notification_${SEED++}`
const idx = orientedNotifications.findIndex(
({ vm }) => vm.component?.props.id === id,
)
if (idx > -1)
return
let verticalOffset = options.offset || 0
notifications[options.placement!].forEach(({ vm }) => {
verticalOffset += (vm.el?.offsetHeight || 0) + GAP_SIZE
})
verticalOffset += GAP_SIZE
const userOnClose = options.onClose
const props = {
...options,
offset: verticalOffset,
id,
onClose: () => {
close(id, options.placement!, userOnClose)
},
}
const container = document.createElement('div')
const vm = createVNode(
NotificationConstructor,
props,
options.text
? {
default: () => options.text,
}
: null,
)
vm.appContext = context ?? notify._context
vm.props!.onDestroy = () => {
render(null, container)
}
render(vm, container)
notifications[options.placement!].push({ vm })
document.body.appendChild(container.firstElementChild!)
return {
close: () => {
vm.component!.exposed!.close()
},
}
}
export function close(
id: NotificationOptions['id'],
placement: NotificationOptions['placement'],
userOnClose: NotificationOptions['onClose'],
) {
const orientedNotifications = notifications[placement!]
const idx = orientedNotifications.findIndex(
({ vm }) => vm.component?.props.id === id,
)
if (idx === -1)
return
const { vm } = orientedNotifications[idx]
if (!vm)
return
userOnClose?.(vm)
const removedHeight = vm.el!.offsetHeight
const verticalPos = placement!.split('-')[0]
orientedNotifications.splice(idx, 1)
if (orientedNotifications.length < 1)
return
for (let i = idx; i < orientedNotifications.length; i++) {
const { el, component } = orientedNotifications[i].vm
const styles = getComputedStyle(el as Element)
const pos = Number.parseInt(styles.getPropertyValue(verticalPos), 10)
component!.props.offset = pos - removedHeight - GAP_SIZE
}
}
export function closeAll() {
for (const orientedNotifications of Object.values(notifications)) {
orientedNotifications.forEach(({ vm }) => {
vm.component!.exposed!.close()
})
}
}
notify.closeAll = closeAll
notify._context = null
export default notify

View File

@@ -0,0 +1,33 @@
import type { VNode } from 'vue'
export const NOTIFICATION_TYPES = [
'neutral',
'positive',
'warning',
'negative',
] as const
export type NotificationType = (typeof NOTIFICATION_TYPES)[number]
export type NotificationPlacement =
| 'top-right'
| 'top-left'
| 'bottom-right'
| 'bottom-left'
export interface NotificationOptions {
type?: NotificationType
text: string
title?: string
duration?: number
placement?: NotificationPlacement
id?: string | number
offset?: number
onClose?: (vm?: VNode) => void
}
export interface NotificationItem {
vm: VNode
}
export type NotificationQueue = NotificationItem[]