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>
}

View File

@@ -0,0 +1,73 @@
<template>
<div
:class="[
cn.b(),
cn.m(type),
cn.m(size),
cn.has('title', hasTitle),
cn.has('action', hasAction),
]"
>
<div :class="[cn.e('content')]">
<div
v-if="$slots.title || title"
:class="[cn.e('title')]"
>
<slot name="title">
{{ title }}
</slot>
</div>
<div :class="[cn.e('text')]">
<slot>{{ text }}</slot>
</div>
<slot name="action" />
</div>
<div :class="cn.e('icon')">
<slot name="icon">
<Component :is="resolveComponent(`ui-icon-${TYPE_ICON_MAPPING[type]}`)" />
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import useClassname from '../../composables/use-classname'
import type { AlertProps, AlertType } from './types'
import type { UiIcon } from '#build/types/ui/icons'
defineOptions({
name: 'UiAlert',
})
const props = withDefaults(defineProps<AlertProps>(), {
type: 'neutral',
size: 'normal',
})
const slots = defineSlots<{
default(props: NonNullable<unknown>): never
title(props: NonNullable<unknown>): never
action(props: NonNullable<unknown>): never
}>()
const TYPE_ICON_MAPPING: Record<AlertType, UiIcon> = {
neutral: 'exclamation-filled',
positive: 'confirmed-filled',
warning: 'danger-filled',
negative: 'cancel-filled',
marketing: 'ask-for-discount-filled',
}
const cn = useClassname('ui-alert')
const hasTitle = computed(() => !!props.title || slots.title)
const hasAction = computed(() => slots.action)
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,83 @@
@use '../../styles/mixins' as *;
@use '../../styles/variables' as *;
.ui-alert {
$self: &;
--icon-color: var(--alert-icon-color);
display: flex;
align-items: center;
padding: 8px 16px;
border-radius: 12px;
background-color: var(--alert-background);
text-align: left;
&__title {
@include txt-i-b('alert-title');
grid-area: title;
margin-bottom: var(--alert-title-margin, 4px);
margin-top: 2px;
}
&__text {
@include txt-i-m('alert-text');
grid-area: text;
color: $clr-black;
#{$self}.has-title & {
color: var(--alert-text-color, $clr-grey-500);
}
#{$self}.has-action & {
margin-bottom: 4px;
}
}
&__icon {
grid-area: icon;
align-self: center;
color: var(--icon-color);
padding: 8px;
#{$self}.has-title &,
#{$self}.has-action & {
align-self: flex-start
}
}
&__content{
flex: 1;
}
@include element-variant('alert', 'large', (
'padding': 16px,
));
@include element-variant('alert', 'neutral', (
'icon-color': $clr-grey-500,
'background': $clr-grey-200,
));
@include element-variant('alert', 'positive', (
'icon-color': $clr-green-500,
'background': $clr-green-100,
));
@include element-variant('alert', 'warning', (
'icon-color': $clr-warn-500,
'background': $clr-warn-100,
));
@include element-variant('alert', 'negative', (
'icon-color': $clr-red-500,
'background': $clr-red-100,
));
@include element-variant('alert', 'marketing', (
'icon-color': $clr-market-500,
'background': $clr-market-100,
));
}

View File

@@ -0,0 +1,16 @@
export const ALERT_TYPES = [
'neutral',
'positive',
'warning',
'negative',
'marketing',
] as const
export type AlertType = (typeof ALERT_TYPES)[number]
export interface AlertProps {
type?: AlertType
title?: string
text?: string
size?: string
}

View File

@@ -0,0 +1,39 @@
<template>
<div :class="[cn.b(), cn.m(type)]">
<slot name="prefix">
<Component
:is="resolveComponent(`ui-icon-${icon}`)"
v-if="icon"
/>
</slot>
<slot> {{ text }}</slot>
<slot name="suffix" />
</div>
</template>
<script setup lang="ts">
import type { BadgeType } from './types'
import type { UiIcon } from '#build/types/ui/icons'
export interface Props {
type?: BadgeType
text?: string
icon?: UiIcon
}
defineOptions({
name: 'UiBadge',
})
const props = withDefaults(defineProps<Props>(), {
type: 'neutral',
})
const cn = useClassname('ui-badge')
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,47 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
@use 'sass:selector';
.ui-badge {
@include txt-s-m;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4.5px 8.73px;
border-radius: 8px;
color: var(--badge-color);
background-color: var(--badge-background);
white-space: nowrap;
@include element-variant('badge', 'neutral', (
'color': $clr-cyan-500,
'background': $clr-grey-300,
));
@include element-variant('badge', 'positive', (
'color': $clr-green-500,
'background': $clr-green-100,
));
@include element-variant('badge', 'warning', (
'color': $clr-warn-500,
'background': $clr-warn-200,
));
@include element-variant('badge', 'negative', (
'color': $clr-red-500,
'background': $clr-red-100,
));
@include element-variant('badge', 'marketing', (
'color': $clr-white,
'background': $clr-cyan-300,
));
@include element-variant('badge', 'extended', (
'color': $clr-cyan-500,
'background': $clr-white,
));
}

View File

@@ -0,0 +1,16 @@
export const BADGE_TYPES = [
'neutral',
'positive',
'warning',
'negative',
'marketing',
'extended',
] as const
export type BadgeType = (typeof BADGE_TYPES)[number]
export interface AlertProps {
type?: BadgeType
title?: string
text?: string
}

View File

@@ -0,0 +1,145 @@
<template>
<Component
:is="as"
:class="[
cn.b(),
cn.m(type),
cn.m(`${type}_${color}`),
cn.m(size !== 'medium' ? size : ''),
cn.is('one-icon-only', oneIconOnly),
cn.has('icon', hasIcon),
cn.is('disabled', disabled),
cn.is('hover', state === 'hover'),
cn.is('active', state === 'active'),
]"
:disabled="disabled"
v-bind="attributes"
@click="handleClick"
>
<span
v-if="hasLeftIcon && displayIcon"
:class="[cn.e('icon'), cn.em('icon', 'left')]"
>
<slot name="left-icon">
<Component :is="resolveComponent(`ui-icon-${leftIcon}`)" />
</slot>
</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">
<Component :is="resolveComponent(`ui-icon-${rightIcon}`)" />
</slot>
</span>
<span
v-if="loading && !disabled"
:class="[cn.e('loader')]"
>
<slot name="loader">
<UiSpinner
:delay="typeof loading === 'number' ? loading : undefined"
@done="$emit('update:loading', false)"
/>
</slot>
</span>
</Component>
</template>
<script setup lang="ts">
import type { ButtonHTMLAttributes } from 'vue'
import type { UiIcon } from '#build/types/ui/icons'
export interface Props {
type?: 'filled' | 'outlined' | 'ghost' | 'link'
color?: 'primary' | 'secondary'
size?: 'small' | 'medium' | 'large'
nativeType?: ButtonHTMLAttributes['type']
icon?: UiIcon
leftIcon?: UiIcon
rightIcon?: UiIcon
disabled?: boolean
loading?: boolean | number
state?: 'hover' | 'active'
}
defineOptions({
name: 'UiButton',
})
const props = withDefaults(defineProps<Props>(), {
type: 'filled',
color: 'primary',
size: 'medium',
nativeType: 'button',
disabled: false,
loading: false,
})
const emit = defineEmits<{
click: [e: Event]
'update:loading': []
}>()
const slots = useSlots()
const attrs = useAttrs()
const linkComponent = useGlobalConfig('LINK_COMPONENT', 'a')
const cn = useClassname('ui-button')
const as = computed(() => (attrs.href ? linkComponent.value : 'button'))
const attributes = computed(() => {
if (as.value === 'button') {
return {
type: props.nativeType,
...attrs,
}
}
return attrs
})
const leftIcon = computed(() => props.leftIcon ?? props.icon)
const hasLeftIcon = computed(() => !!leftIcon.value || !!slots.leftIcon)
const hasRightIcon = computed(() => !!props.rightIcon || !!slots.rightIcon)
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 || props.loading) && !slots.default,
)
const displayIcon = computed(
() => hasIcon.value && ((props.loading && props.disabled) || !props.loading),
)
function handleClick(event: Event) {
if (props.disabled || props.loading) {
event.stopPropagation()
event.preventDefault()
}
else {
emit('click', event)
}
}
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,240 @@
@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: [
[
'filled',
'primary',
(
'color': $clr-white,
'background': $clr-cyan-500,
'hover-background': $clr-cyan-400,
'active-background': $clr-cyan-600,
'disabled-background': $clr-cyan-200,
'disabled-color': $clr-cyan-300
)
],
[
'filled',
'secondary',
(
'color': $clr-cyan-500,
'background': $clr-white,
'hover-background': $clr-grey-200,
'active-background': $clr-grey-300,
'disabled-background': $clr-grey-100,
'disabled-color': $clr-grey-400
)
],
[
'outlined',
'primary',
(
'color': $clr-cyan-500,
'border-color': $clr-cyan-500,
'hover-color': $clr-white,
'hover-background': $clr-cyan-500,
'active-color': $clr-white,
'active-background': $clr-cyan-600,
'disabled-border-color': $clr-cyan-300,
'disabled-color': $clr-cyan-300
)
],
[
'outlined',
'secondary',
(
'color': $clr-grey-500,
'icon-color': $clr-grey-400,
'border-color': $clr-grey-300,
'hover-color': $clr-grey-600,
'hover-background': $clr-grey-200,
'active-color': $clr-cyan-700,
'active-background': $clr-grey-300,
'disabled-border-color': $clr-grey-300,
'disabled-color': $clr-grey-400
)
],
[
'ghost',
'primary',
(
'color': $clr-cyan-400,
'hover-color': $clr-cyan-500,
'hover-background': $clr-grey-200,
'active-color': $clr-cyan-600,
'active-background': $clr-grey-300,
'disabled-color': $clr-cyan-300
)
],
[
'ghost',
'secondary',
(
'color': $clr-grey-500,
'icon-color': $clr-grey-400,
'hover-color': $clr-grey-600,
'hover-background': $clr-grey-200,
'active-color': $clr-cyan-700,
'active-background': $clr-grey-300,
'disabled-color': $clr-grey-400
)
],
[
'link',
'primary',
(
'color': $clr-cyan-500,
'hover-color': $clr-cyan-400,
'active-color': $clr-cyan-600,
'disabled-color': $clr-cyan-300
)
],
[
'link',
'secondary',
(
'color': $clr-grey-500,
'icon-color': $clr-grey-400,
'hover-color': $clr-cyan-500,
'active-color': $clr-cyan-600,
'disabled-color': $clr-grey-400
)
]
];
.ui-button {
$self: &;
@include txt-i-m('button');
--spinner-size: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
cursor: pointer;
outline: none;
padding: 7px 15px;
background: var(--button-background, transparent);
color: var(--button-color, inherit);
border-radius: var(--button-border-radius, 12px);
width: var(--button-width, unset);
height: var(--button-height, 40px);
border: 1px solid var(--button-border-color, transparent);
white-space: nowrap;
transition: 0.2s ease-out;
transition-property: background-color, border-color, color;
&:visited {
color: var(--button-color, inherit);
}
&:hover,
&:focus-visible,
&.is-hover {
--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,
&.is-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 {
--button-icon-color: var(--button-icon-disabled-color, var(--button-color));
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 .2s ease-out;
#{$self}.is-one-icon-only:not(#{$self}--link) & {
color: inherit;
}
}
&__loader {
cursor: wait;
#{$self}--small & {
--spinner-size: 16px;
}
}
&.is-one-icon-only {
padding: 0;
width: var(--button-width, var(--button-height, 40px));
}
&--small {
@include txt-s-m;
--button-height: 32px;
padding: 7px 11px;
border-radius: 8px;
}
&--large {
@include txt-m-m;
--button-height: 48px;
padding: 11px 23px;
}
@each $variant in $button-variants {
$type: list.nth($variant, 1);
$color: list.nth($variant, 2);
$scheme: list.nth($variant, 3);
&--#{$type}_#{$color} {
@each $property, $value in $scheme {
--button-#{$property}: var(--button-#{$type}-#{$color}-#{$property});
}
}
/* prettier-ignore */
@include element-variant('button', $type + '-' + $color, $scheme);
}
&--link {
&_primary,
&_secondary {
width: auto !important;
height: auto !important;
padding: 0 !important;
border: none !important;
background: none !important;
text-decoration: none;
}
}
&--link#{$self}--small {
@include txt-r-m;
}
}

View File

@@ -0,0 +1,216 @@
<template>
<div :class="cn.b()">
<div :class="cn.e('header')">
<UiButton
icon="chevron-left"
size="small"
type="ghost"
color="secondary"
@click="previousMonth"
/>
<p :class="cn.e('title')">
{{ currentMonth }}
</p>
<UiButton
icon="chevron-right"
size="small"
type="ghost"
color="secondary"
@click="nextMonth"
/>
</div>
<div :class="cn.e('week-days')">
<div
v-for="dayOfWeek in daysOfWeek"
:key="dayOfWeek"
>
{{ dayOfWeek }}
</div>
</div>
<div :class="cn.e('days')">
<div
v-for="day in monthDays"
:key="day.date"
:class="getDayClasses(day)"
@click="selectDay(day)"
>
{{ day.day }}
</div>
</div>
<UiButton
type="outlined"
color="secondary"
:class="cn.e('apply-button')"
@click="apply"
>
{{ $t('apply') }}
</UiButton>
</div>
</template>
<script setup>
// TODO: TypeScript
import { computed, reactive, ref, toRef, watch } from 'vue'
import dayjs from 'dayjs'
import UiButton from '../button/index.vue'
import useClassname from '../../composables/use-classname'
const props = defineProps({
allowRangeSelection: {
type: Boolean,
default: true,
},
modelValue: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const cn = useClassname('ui-calendar')
const modelValue = toRef(props, 'modelValue')
const today = dayjs()
const dateRange = reactive({
start: null,
end: null,
})
const currentDate = ref(today)
const daysOfWeek = dayjs.weekdaysMin()
const currentMonth = computed(() => currentDate.value.format('MMMM, YYYY'))
const monthDays = computed(() => {
const daysInMonth = currentDate.value.daysInMonth()
const leadingDays = currentDate.value.startOf('month').day()
const days = []
for (let i = 0; i < leadingDays; i++) {
const date = currentDate.value
.subtract(1, 'month')
.endOf('month')
.subtract(leadingDays - i - 1, 'day')
days.push(date)
}
for (let i = 0; i < daysInMonth; i++) {
const date = currentDate.value.date(i + 1)
days.push(date)
}
const num = 7 - (days.length % 7 || 7)
for (let i = 0; i < num; i++) {
const date = currentDate.value.add(1, 'month').date(i + 1)
days.push(date)
}
return days.map(formatDateObject)
})
function formatDateObject(date) {
return {
raw: date,
day: date.date(),
month: date.get('month'),
isToday: date.isToday(),
isCurrentMonth: date.isSame(currentDate.value, 'month'),
isPeriod:
!!dateRange.start
&& !!dateRange.end
&& date.isBetween(dateRange.start, dateRange.end, 'day', '[]'),
isPeriodStart: !!dateRange.start && date.isSame(dateRange.start, 'day'),
isPeriodEnd: !!dateRange.end && date.isSame(dateRange.end, 'day'),
}
}
function nextMonth() {
currentDate.value = currentDate.value.add(1, 'month')
}
function previousMonth() {
currentDate.value = currentDate.value.subtract(1, 'month')
}
function selectDay(day) {
let { start, end } = dateRange
if (props.allowRangeSelection) {
const isSameStartDay = day.raw.isSame(start, 'day')
if (isSameStartDay) {
start = null
end = null
}
else if (!start || end) {
start = day.raw
end = null
}
else {
end = day.raw
if (dayjs(start).isAfter(dayjs(end), 'day'))
[start, end] = [end, start]
}
}
else {
start = day.raw
end = null
}
dateRange.start = start
dateRange.end = end
}
watch(
modelValue,
(value) => {
const [start, end] = value
dateRange.start = !!start && dayjs(start)
dateRange.end = !!end && dayjs(end)
},
{ immediate: true },
)
function getDayClasses(day) {
const classes = [cn.e('cell')]
if (day.isCurrentMonth)
classes.push(cn.em('cell', 'current-month'))
if (day.isPeriod)
classes.push(cn.em('cell', 'period'))
if (day.isPeriodStart)
classes.push(cn.em('cell', 'period-start'))
else if (day.isPeriodEnd)
classes.push(cn.em('cell', 'period-end'))
if (day.isToday)
classes.push(cn.em('cell', 'current-day'))
return classes
}
function apply() {
const value = []
if (dateRange.start)
value.push(dateRange.start.startOf('day').valueOf())
if (dateRange.end)
value.push(dateRange.end.startOf('day').valueOf())
emit('update:modelValue', value)
}
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,140 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.ui-calendar {
display: inline-block;
width: fit-content;
background-color: var(--calendar-background);
color: var(--calendar-color);
padding: 16px 16px 24px 16px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
i {
&:hover {
cursor: pointer;
}
}
}
&__title {
@include txt-i-sb;
text-transform: capitalize;
}
&__week-days,
&__days {
display: grid;
grid-template-columns: repeat(7, 32px);
color: var(--calendar-day-color);
div {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
}
&__week-days {
@include txt-s;
grid-auto-rows: 24px;
text-transform: capitalize;
}
&__days {
@include txt-i-m;
grid-auto-rows: 32px;
}
&__apply-button {
width: 100%;
margin-top: 16px;
}
&__cell {
transition: 0.2s ease-out;
transition-property: background-color;
&:not(&--period-start, &--period-end, &--period):hover {
background: var(--calendar-day-background-hover-color);
border-radius: 8px;
}
&--current-month {
color: var(--calendar-current-month-day-color);
}
&--current-day {
color: var(--calendar-today-color);
}
&--period {
background-color: var(--calendar-period-background);
}
&--period-start,
&--period-end {
border-radius: 8px;
position: relative;
color: var(--calendar-period-start-end-color);
z-index: 2;
&::before,
&::after {
content: '';
position: absolute;
top: 0;
height: 100%;
z-index: -1;
}
&::before {
width: 50%;
background-color: var(--calendar-period-background);
}
&::after {
width: 100%;
background-color: var(--calendar-period-start-end-background);
border-radius: inherit;
left: 0;
}
}
&--period-start {
&::before {
right: 0;
}
}
&--period-end {
&::before {
left: 0;
}
}
}
/* prettier-ignore */
@include element-variant(
'calendar',
'',
(
'color': $clr-black,
'background': $clr-white,
'day-color': $clr-grey-500,
'current-month-day-color': $clr-black,
'today-color': $clr-cyan-500,
'arrow-color': $clr-grey-600,
'period-start-end-color': $clr-white,
'period-start-end-background': $clr-cyan-500,
'period-background': $clr-grey-100,
'day-background-hover-color': $clr-grey-100
)
);
}

View File

@@ -0,0 +1,74 @@
<template>
<div
:class="[
cn.b(),
cn.is('checked', checked),
cn.is('invalid', invalid),
cn.is('disabled', disabled),
cn.is('focused', focused),
cn.has('label', hasLabel),
]"
>
<label :class="[cn.e('wrapper')]">
<input
:name="id"
:class="[cn.e('input')]"
type="checkbox"
:disabled="disabled"
:checked="checked"
@focus="focused = true"
@blur="focused = false"
@change="handleChange"
>
<UiIconSCheck :class="[cn.e('checkmark')]" />
<p :class="[cn.e('label')]">
<slot>{{ label }}</slot>
</p>
</label>
</div>
</template>
<script setup lang="ts">
import { useSlots } from 'vue'
import useClassname from '../../composables/use-classname'
export interface Props {
id: string
label?: string
disabled?: boolean
modelValue?: boolean | string | number
trueValue?: boolean | string | number
falseValue?: boolean | string | number
required?: boolean
}
defineOptions({
name: 'UiCheckbox',
})
const props = withDefaults(defineProps<Props>(), {
disabled: false,
trueValue: true,
falseValue: false,
required: false,
modelValue: undefined,
})
defineEmits<{
'update:modelValue': [state: boolean | string | number]
focus: []
blur: []
}>()
const slots = useSlots()
const cn = useClassname('ui-checkbox')
const { checked, invalid, focused, hasLabel, handleChange } = useCheckbox(props, slots)
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,98 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.ui-checkbox {
$self: &;
--size: 16px;
display: flex;
&__wrapper {
display: inline-flex;
position: relative;
}
&__input {
width: 0;
height: 0;
opacity: 0;
cursor: pointer;
z-index: -1;
margin: 0;
}
&__checkmark {
--border-color: var(--checkbox-border-color);
--border-width: 1px;
width: var(--size);
height: var(--size);
border-radius: 4px;
outline: var(--border-width) solid var(--border-color);
outline-offset: calc(var(--border-width) * -1);
color: transparent;
cursor: pointer;
transition: .2s ease-out;
transition-property: outline-color, background-color, color;
#{$self}.has-label & {
margin-top: 1px;
}
#{$self}__input:focus-visible + &,
#{$self}:not(.is-disabled):not(.is-checked) &:hover {
--border-width: 2px;
}
#{$self}.is-checked & {
--border-width: 0px;
color: var(--checkbox-checked-color);
background-color: var(--checkbox-checked-background);
}
#{$self}.is-checked #{$self}__input:focus-visible + &,
#{$self}:not(.is-disabled).is-checked &:hover {
color: var(--checkbox-checked-hover-color);
}
#{$self}.is-invalid & {
--border-color: var(--checkbox-invalid-border-color);
}
#{$self}.is-disabled & {
--border-color: var(--checkbox-disabled-border-color);
cursor: not-allowed;
}
#{$self}.is-disabled.is-checked & {
color: var(--checkbox-disabled-checked-color);
}
}
&__label {
@include txt-i-m;
margin-left: 8px;
color: var(--checkbox-label-color);
#{$self}.is-disabled & {
color: var(--checkbox-label-disabled-color);
cursor: not-allowed;
}
}
@include element-variant('checkbox', '', (
'border-color': $clr-grey-400,
'checked-color': $clr-grey-600,
'checked-background': $clr-grey-300,
'checked-hover-color': $clr-grey-500,
'invalid-border-color': $clr-red-500,
'disabled-border-color': $clr-grey-300,
'disabled-checked-color': $clr-grey-400,
'label-color': $clr-black,
'label-disabled-color': $clr-grey-400
))
}

View File

@@ -0,0 +1,209 @@
<template>
<div :class="[cn.b(), cn.is('invalid', invalid)]">
<div :class="[cn.e('label')]">
<slot name="title">
<p v-if="!hideTitle">
{{ title }}
</p>
</slot>
</div>
<div :class="[cn.e('wrapper')]">
<input
v-for="i in 6"
:key="i"
ref="digitInputs"
type="text"
:class="[cn.e('digit')]"
:value="value.charAt(i - 1)"
inputmode="numeric"
@focus="handleFocus(i)"
@blur="onBlur"
@keydown="handleKeydown(i, $event)"
@paste.prevent="handleClipboardPaste"
>
</div>
<p
v-if="invalid"
:class="[cn.e('validation-message')]"
>
{{ realMessage }}
</p>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, toRef } from 'vue'
import { useField } from 'vee-validate'
import useClassname from '../../composables/use-classname'
const props = defineProps({
id: {
type: String,
required: true,
},
title: {
type: String,
default: '2FA',
},
modelValue: {
type: String,
default: '',
},
invalid: {
type: Boolean,
default: false,
},
autofocus: {
type: Boolean,
default: false,
},
hideTitle: {
type: Boolean,
default: false,
},
})
defineEmits(['update:modelValue'])
const { t } = useI18n({ useScope: 'global' })
const cn = useClassname('ui-code-input')
const { value, errorMessage, meta, handleChange, handleBlur } = useField(
toRef(props, 'id'),
'required|length:6',
{
validateOnValueUpdate: false,
initialValue: props.modelValue,
syncVModel: true,
},
)
const digitInputs = ref(null)
let ignoreBlurEvent = false
const invalid = computed(
() => props.invalid || (!!errorMessage.value && meta.touched),
)
const realMessage = computed(() =>
props.invalid ? t('validation.invalid_code') : errorMessage.value,
)
function handleFocus(index) {
if (value.value.length < 6 && index > value.value.length)
digitInputs.value[value.value.length].focus()
}
async function handleKeydown(index, event) {
const currentValue = value.value
let nextInput
if (/^\d+$/.test(event.key)) {
const value
= currentValue.substring(0, index - 1)
+ event.key
+ currentValue.substring(index)
handleChange(value, false)
nextInput = digitInputs.value[index]
}
else if (event.key === 'Backspace' && currentValue) {
const currentChar = currentValue.substring(index - 1, index)
handleChange(value.value.substring(0, index - 1), false)
if (!currentChar)
nextInput = digitInputs.value[Math.max(index - 2, 0)]
}
if (
!event.ctrlKey
&& !event.metaKey
&& !['Tab', 'Enter'].includes(event.key)
)
event.preventDefault()
await nextTick()
if (nextInput) {
ignoreBlurEvent = true
event.target.blur()
nextInput.focus()
ignoreBlurEvent = false
}
}
function handleClipboardPaste(event) {
const value = event.clipboardData?.getData('text')
if (/^\d{6}/.test(value)) {
handleChange(value.substring(0, 6))
event.target.blur()
}
}
function onBlur(event) {
if (ignoreBlurEvent)
return
handleBlur(event)
}
onMounted(() => {
if (props.autofocus)
digitInputs.value[0].focus()
})
</script>
<style lang="scss">
.ui-code-input {
text-align: left;
&__wrapper {
display: flex;
}
&__label {
@include txt-m-b('code-input-label');
margin-bottom: var(--code-input-label-margin, 8px);
}
&__digit {
--border-color: #{$clr-grey-300};
--border-width: 1px;
width: var(--code-input-size, 62px);
height: var(--code-input-size, 62px);
border-radius: var(--code-input-border-radius, 16px);
outline: var(--border-width) solid var(--border-color);
outline-offset: calc(var(--border-width) * -1);
border: none;
text-align: center;
padding: 0;
transition: outline-color 0.2s ease-out;
caret-color: $clr-cyan-500;
&:focus {
--border-width: 2px;
--border-color: #{$clr-cyan-500};
}
&:not(:first-child) {
margin-left: var(--code-input-gap, 25px);
}
}
&.is-invalid {
--code-input-border-color: #{$clr-red-500};
}
&__validation-message {
margin-top: 8px;
color: $clr-red-500;
}
}
</style>

View File

@@ -0,0 +1 @@
["1inch", "aave", "abcoin", "abrub", "ach", "acrub", "ada", "advcusd", "alfauah", "algo", "ankr", "ant", "atom", "avax", "avbrub", "bat", "bch", "btc", "btcbep20", "btg", "btt", "busd", "cardrub", "cashrub", "dai", "dash", "dent", "doge", "dot", "eos", "etc", "eth", "euro", "exmrub", "fantom", "gala", "gbp", "gcmtrub", "ghst", "gno", "gpbrub", "grntxrub", "hcbrub", "icx", "imx", "iota", "kmd", "kukrub", "link", "lsk", "ltc", "mana", "mir", "mircrub", "mkr", "monobuah", "mtsbrub", "mwrub", "near", "neo", "omg", "ont", "opnbrub", "osdbuah", "p24uah", "pmbbuah", "pmusd", "polygon", "postbrub", "prrub", "psbrub", "qtum", "quick", "qwrub", "rep", "rfbrub", "rnkbrub", "rosbrub", "rshbrub", "russtrub", "rvn", "sberrub", "sberuah", "sbprub", "shib", "sol", "stellar", "tbrub", "tcsbrub", "ton", "tru", "trx", "tusd", "uni", "usbuah", "usd coin", "usd", "usdp", "usdtomni", "vet", "waves", "wbtc", "weth", "wirerub", "wmz", "xem", "xlm", "xmr", "xrp", "xtz", "xvg", "yamrub", "yfi", "zec", "zrx"]

View File

@@ -0,0 +1,117 @@
<template>
<img
v-if="src || (!src && !hideWhenNoSrc)"
:class="[
cn.b(),
cn.is('circle', circle),
cn.is('fallback', isFallback),
cn.is('empty', !src),
]"
:src="src"
:alt="code"
draggable="false"
>
</template>
<script>
/** FROM -> TO */
</script>
<script setup>
// TODO: Typescript
import { computed, toRef } from 'vue'
import useClassname from '../../composables/use-classname'
import AVAILABLE_COINS from './available-coins.json'
const props = defineProps({
code: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
circle: {
type: Boolean,
default: false,
},
fallbackImg: {
type: String,
default: undefined,
},
hideWhenNoSrc: {
type: Boolean,
default: false,
},
})
const DIRECT_ALIASES = {
toncoin: 'ton',
bnb: 'btcbep20',
ftm: 'fantom',
matic: 'polygon',
avaxc: 'avax',
bsc: 'btcbep20',
btctest: 'btc',
lend: 'polygon',
rub: 'abrub',
poly: 'polygon',
usdterc20: 'usdtomni',
}
const REGEX_ALIASES = {
'acrub': /^acc/,
'usdtomni': /^usdt/,
'wirerub': /^wire/,
'eth': /^eth/,
'usd coin': /^usdc/,
'btcbep20': /^bnb/,
'tusd': /^tusd/,
'tcsbrub': /^tcs/,
'shib': /^shib/,
'dai': /^dai/,
'cake': /^cake/,
}
const cn = useClassname('ui-coin')
const fallbackImg = toRef(props, 'fallbackImg')
const name = computed(() => props.code.toLowerCase())
const notAvailable = computed(() => !AVAILABLE_COINS.includes(name.value))
const alias = computed(() => {
if (!notAvailable.value)
return null
const direct = DIRECT_ALIASES[name.value]
if (direct)
return direct
for (const [target, pattern] of Object.entries(REGEX_ALIASES)) {
if (pattern.test(name.value))
return target
}
return null
})
const isFallback = computed(
() => notAvailable.value && !alias.value && fallbackImg.value,
)
const src = computed(() => {
if (!name.value || (notAvailable.value && !alias.value))
return fallbackImg.value
const code = alias.value ?? name.value
return `/coins/${code}.svg`
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,23 @@
.ui-coin {
--size: var(--coin-size, 32px);
position: relative;
display: inline-block;
width: var(--size);
height: var(--size);
border-radius: var(--coin-border-radius, 6px);
outline: 1px solid var(--coin-border-color, rgba(255, 255, 255, 0.24));
outline-offset: -1px;
&.is-fallback {
overflow: hidden;
}
&.is-empty {
visibility: hidden;
}
&.is-circle {
border-radius: 50%;
}
}

View File

@@ -0,0 +1,74 @@
<template>
<div :class="[cn.b(), cn.is('active', pressed)]">
<UiButton
right-icon="s-copy"
size="small"
type="link"
color="secondary"
:class="cn.e('default')"
@click="copyHandler()"
>
<template
v-if="title"
#default
>
{{ title }}
</template>
</UiButton>
<div :class="cn.e('copied')">
<span v-if="pressedTitle">
{{ pressedTitle }}
</span>
<UiIconSCheck />
</div>
</div>
</template>
<script setup lang="ts">
import UiButton from '../button/index.vue'
import useClassname from '../../composables/use-classname'
export interface Props {
title?: string
pressedTitle?: string
value: string | (() => Promise<string>)
}
defineOptions({
name: 'UiCopyButton',
})
const props = withDefaults(defineProps<Props>(), {
title: 'Copy',
pressedTitle: 'Copied',
})
const cn = useClassname('ui-copy-button')
const pressed = ref(false)
async function copyHandler() {
pressed.value = true
const valueToCopy
= typeof props.value === 'function' ? props.value() : props.value
try {
await navigator.clipboard.writeText(await valueToCopy)
}
catch {}
await new Promise(resolve => setTimeout(resolve, 2000))
pressed.value = false
}
defineExpose({
copy: copyHandler,
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,52 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.ui-copy-button {
$self: &;
@include txt-r-m;
display: inline-block;
position: relative;
height: 16px;
text-align: left;
width: min-content;
user-select: none;
vertical-align: middle;
&__default,
&__copied {
transition: .2s ease-in-out;
transition-property: transform, opacity;
transform-origin: center;
#{$self}.is-active & {
transform: translateY(-16px);
}
}
&__default {
opacity: 1;
#{$self}.is-active & {
opacity: 0;
pointer-events: none;
}
}
&__copied {
@include txt-r-m;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
color: $clr-cyan-500;
opacity: 0;
pointer-events: none;
#{$self}.is-active & {
opacity: 1;
}
}
}

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
}

View File

@@ -0,0 +1,329 @@
<template>
<div
:class="[
cn.b(),
cn.m(nativeType),
cn.is('invalid', invalid),
cn.is('disabled', disabled),
cn.is('focused', focused),
cn.is('readonly', readonly),
cn.has('value', isFilled),
]"
>
<label :class="[cn.e('wrapper')]">
<div :class="[cn.e('content')]">
<input
ref="inputRef"
:inputmode="inputmode"
:name="id"
:class="[cn.e('control')]"
:type="inputType"
:autocomplete="autocomplete"
:disabled="disabled"
:value="inputValue"
:readonly="readonly"
:tabindex="readonly ? -1 : 0"
@input="handleInput"
@focus="onFocus"
@blur="onBlur"
@change="onChange"
>
<p :class="[cn.e('label')]">{{ label }}</p>
<div
v-if="renderSuffixSlot"
:class="[cn.e('suffix')]"
>
<slot name="suffix">
<UiButton
v-if="showPasswordToggler"
type="ghost"
color="secondary"
size="small"
:icon="showPassword ? 'non-visibility' : 'visibility'"
@click="togglePassword"
@mousedown.prevent
/>
<UiButton
v-else-if="clearable"
icon="cross"
type="ghost"
color="secondary"
size="small"
@click="handleChange('', false)"
@mousedown.prevent
/>
<UiButton
v-else-if="copyable"
type="ghost"
color="secondary"
size="small"
icon="s-copy"
@click="copy"
@mousedown.prevent
/>
</slot>
</div>
</div>
</label>
<div
v-if="!readonly && (!!rules || !!caption || $slots.caption)"
:class="[cn.e('bottom')]"
>
<span
v-if="invalid"
:class="[cn.e('validation-message')]"
>
{{ errorMessage }}
</span>
<slot
v-else-if="caption || $slots.caption"
name="caption"
>
<span :class="[cn.e('caption')]">
{{ caption }}
</span>
</slot>
</div>
</div>
</template>
<script setup lang="ts">
import { useField } from 'vee-validate'
import IMask from 'imask'
import { isEqual } from 'lodash-es'
import type { InputHTMLAttributes } from 'vue'
import type { RuleExpression } from 'vee-validate'
import type { FactoryArg as IMaskOptions, InputMask } from 'imask'
export interface Props {
id: string
nativeType?: 'text' | 'password'
label: string
disabled?: boolean
caption?: string
copyable?: boolean
clearable?: boolean
autofocus?: boolean
readonly?: boolean
autocomplete?: InputHTMLAttributes['autocomplete']
inputmode?: InputHTMLAttributes['inputmode']
rules?: RuleExpression<string>
mask?: IMaskOptions
modelValue?: string
}
defineOptions({
name: 'UiInput',
})
const props = withDefaults(defineProps<Props>(), {
nativeType: 'text',
disabled: false,
copyable: false,
clearable: false,
autofocus: false,
readonly: false,
autocomplete: 'off',
inputmode: 'text',
})
defineEmits<{
'update:modelValue': []
focus: []
blur: []
}>()
function isEmptyValue(value: string) {
return [null, undefined, ''].includes(value)
}
const slots = useSlots()
const cn = useClassname('ui-input')
const { value, errorMessage, setValue, handleChange, handleBlur } = useField(
toRef(props, 'id'),
toRef(props, 'rules'),
{
validateOnValueUpdate: false,
initialValue: !isEmptyValue(props.modelValue)
? props.modelValue
: undefined,
syncVModel: true,
},
)
const inputRef = ref(null)
const maskInstance = shallowRef<InputMask<IMaskOptions>>()
const maskOptions = toRef(props, 'mask')
const showPassword = ref(false)
const focused = ref(false)
const maskedValue = ref()
let valueEvent = false
let acceptEvent = false
let updateOptionsEvent = false
const active = computed(() => !props.disabled)
const invalid = computed(() => active.value && !!errorMessage.value)
const inputValue = computed(() => {
return maskInstance.value ? maskedValue.value : value.value
})
const isFilled = computed(() => !isEmptyValue(inputValue.value))
const isPassword = computed(() => props.nativeType === 'password')
const showPasswordToggler = computed(() => isPassword.value && active.value)
const copyable = computed(() => {
if (isPassword.value)
return false
return props.copyable && isFilled.value
})
const clearable = computed(
() => active.value && props.clearable && isFilled.value,
)
const renderSuffixSlot = computed(() => {
return (
slots.suffix
|| showPasswordToggler.value
|| copyable.value
|| clearable.value
)
})
const inputType = computed(() => {
return !isPassword.value || showPassword.value ? 'text' : props.nativeType
})
function togglePassword() {
showPassword.value = !showPassword.value
}
function focus() {
inputRef.value?.focus()
}
function handleInput(event: Event) {
if (!maskInstance.value)
handleChange(event, !!errorMessage.value)
}
function initializeMask() {
if (props.mask) {
if (!inputRef.value)
return
if (maskInstance.value)
destroyMask()
maskInstance.value = IMask(inputRef.value, props.mask).on('accept', () => {
if (valueEvent || updateOptionsEvent)
return
acceptEvent = true
handleChange(maskInstance.value.unmaskedValue, !!errorMessage.value)
maskedValue.value = maskInstance.value.value
nextTick(() => (acceptEvent = false))
})
maskInstance.value.unmaskedValue = value.value?.toString() || ''
maskedValue.value = maskInstance.value.value
}
}
function destroyMask() {
maskInstance.value?.destroy()
maskInstance.value = null
}
function onChange(event: Event) {
if (!errorMessage.value)
handleChange(maskInstance.value ? maskInstance.value.unmaskedValue : event)
}
function copy() {
navigator.clipboard?.writeText(inputValue.value)
}
watch(value, (value) => {
if (maskInstance.value) {
if (acceptEvent)
return
valueEvent = true
maskInstance.value.unmaskedValue = value?.toString() || ''
maskedValue.value = maskInstance.value.value
nextTick(() => (valueEvent = false))
}
})
watch(maskOptions, (newOptions, prevValue) => {
if (newOptions) {
if (!maskInstance.value) {
initializeMask()
}
else {
if (isEqual(newOptions, prevValue))
return
updateOptionsEvent = true
maskInstance.value.updateOptions(toRaw(newOptions))
maskInstance.value.unmaskedValue = value.value?.toString() || ''
maskedValue.value = maskInstance.value.value
nextTick(() => (updateOptionsEvent = false))
}
}
else {
destroyMask()
}
})
function onFocus(e: Event) {
focused.value = true
}
function onBlur(e: Event) {
focused.value = false
handleBlur(e)
}
onMounted(async () => {
if (!isEmptyValue(props.modelValue) && !props.disabled)
setValue(props.modelValue)
initializeMask()
await nextTick()
if (!maskInstance.value)
setValue(inputRef.value.value, false)
if (props.autofocus)
focus()
})
onUnmounted(destroyMask)
defineExpose({
focused,
maskInstance,
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,139 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
@use 'sass:selector';
.ui-input {
$self: &;
overflow: hidden;
&__wrapper {
--border-color: var(--input-border-color);
--border-width: 1px;
display: block;
overflow: hidden;
position: relative;
background: var(--input-background);
border-radius: 12px;
outline: var(--border-width) solid var(--border-color);
outline-offset: calc(var(--border-width) * -1);
padding-inline: 16px;
transition: outline-color .2s ease-out;
#{$self}:not(.is-disabled) & {
cursor: text;
}
#{$self}:not(.is-disabled):not(.is-readonly) &:hover {
--border-width: 2px;
}
#{$self}:not(.is-readonly).is-focused & {
--border-width: 2px;
--border-color: var(--input-focused-border-color);
}
#{$self}.is-readonly & {
cursor: default;
}
#{$self}.is-invalid & {
--border-color: var(--input-invalid-border-color);
}
#{$self}.is-disabled & {
--border-color: var(--input-disabled-border-color);
background: var(--input-disabled-background);
}
}
&__content {
position: relative;
display: flex;
align-items: center;
}
&__control {
@include txt-i-m;
padding: 22px 0 8px;
width: 100%;
outline: none;
vertical-align: middle;
background-color: transparent;
border: none;
caret-color: var(--input-caret-color);
color: var(--input-color);
&::-ms-reveal,
&::-ms-clear {
display: none;
}
&:-webkit-autofill {
-webkit-background-clip: text;
-webkit-text-fill-color: var(--input-color);
}
#{$self}.is-readonly & {
pointer-events: none;
}
#{$self}.is-disabled & {
color: var(--input-disabled-color);
}
}
&__label {
@include txt-i-m;
position: absolute;
pointer-events: none;
top: 15px;
left: 0;
color: var(--input-label-color);
transform-origin: 0 0;
transition-duration: .2s;
transition-property: transform, color;
transition-timing-function: linear, ease-out;
#{$self}.is-focused &,
#{$self}.has-value & {
transform: translateY(-7px) scale(0.78);
color: var(--input-label-filled-color);
}
}
&__bottom {
@include txt-s-m;
margin-top: 4px;
padding-inline: 16px;
min-height: 14px;
}
&__validation-message {
color: var(--input-validation-message-color);
}
&__caption {
color: var(--input-caption-color);
}
@include element-variant('input', '', (
'border-color': $clr-grey-300,
'background': $clr-white,
'caret-color': $clr-cyan-500,
'color': $clr-black,
'focused-border-color': $clr-cyan-500,
'invalid-border-color': $clr-red-500,
'disabled-border-color': $clr-grey-300,
'disabled-background': $clr-grey-100,
'disabled-color': $clr-grey-500,
'label-color': $clr-grey-400,
'label-filled-color': $clr-grey-500,
'validation-message-color': $clr-red-500,
'caption-color': $clr-grey-600
))
}

View File

@@ -0,0 +1,113 @@
<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 { computed, onMounted, ref } from 'vue'
import useClassname from '../../composables/use-classname'
import UiAlert from '../alert/index.vue'
import type { NotificationPlacement, NotificationType } from './types'
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: 0.2s;
transition-property: opacity, transform, left, right, top, bottom;
z-index: calc(8000 + v-bind('zIndex'));
width: 400px;
transform-origin: right top;
&.top {
top: v-bind('`${offset}px`');
}
&.bottom {
bottom: v-bind('`${offset}px`');
}
&.left {
left: 56px;
}
&.right {
right: 56px;
}
}
</style>

View File

@@ -0,0 +1,134 @@
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 || 103
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
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
notify._context = null
export default notify

View File

@@ -0,0 +1,27 @@
import type { VNode } from 'vue'
import type { AlertType } from '../alert/types'
export type NotificationType = AlertType
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[]

View File

@@ -0,0 +1,69 @@
<template>
<table>
<thead>
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
:class="[header.id]"
:style="{
width: `${header.getSize()}px`,
}"
>
<template v-if="!header.isPlaceholder">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</template>
</th>
</tr>
</thead>
<slot>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="[cell.column.id]"
>
<slot :name="`cell(${cell.column.id})`" v-bind="cell.getContext()">
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</slot>
</td>
</tr>
</tbody>
</slot>
</table>
</template>
<script setup lang="ts">
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
import type { ColumnDef } from '@tanstack/vue-table'
export interface Props {
columns: ColumnDef<unknown>[]
data: unknown[]
}
defineOptions({
name: 'UiPlainTable',
})
const props = defineProps<Props>()
const table = useVueTable({
get data() {
return props.data
},
get columns() {
return props.columns
},
getCoreRowModel: getCoreRowModel(),
})
</script>

View File

@@ -0,0 +1,36 @@
<template>
<div :class="[cn.b(), cn.m(type)]">
<div
:class="cn.e('bar')"
:style="{ width: `${progress}%` }"
/>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: 'UiProgressBar',
})
const props = withDefaults(defineProps<ProgressBarProps>(), {
progress: 0,
})
export interface ProgressBarProps {
progress: number
}
const cn = useClassname('ui-progress-bar')
const type = computed(() => {
if (props.progress <= 75)
return 'normal'
else if (props.progress > 75 && props.progress < 95)
return 'middle'
else
return 'high'
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,33 @@
@use '../../styles/mixins' as *;
@use '../../styles/variables' as *;
.ui-progress-bar {
background-color: var(--progress-bar-background-color);
border-radius: 2px;
height: 4px;
overflow: hidden;
&__bar {
height: 100%;
background-color: var(--progress-bar-color);
transition: ease-out;
transition-property: width, background-color;
transition-duration: .1s, .2s;
}
@include element-variant('progress-bar', 'normal', (
'background-color': $clr-grey-300,
'color': $clr-cyan-500,
));
@include element-variant('progress-bar', 'middle', (
'background-color': $clr-warn-200,
'color': $clr-warn-500,
));
@include element-variant('progress-bar', 'high', (
'background-color': $clr-red-100,
'color': $clr-red-500,
));
}

View File

@@ -0,0 +1,29 @@
<template>
<canvas ref="canvasEl" />
</template>
<script setup>
import QRCode from 'qrcode'
const props = defineProps({
value: {
type: String,
required: true,
},
size: {
type: Number,
default: 128,
},
})
const canvasEl = ref()
onMounted(async () => {
try {
await QRCode.toCanvas(canvasEl.value, props.value, { margin: 1, width: props.size })
}
catch (e) {
console.error(e)
}
})
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div
:class="[
cn.b(),
cn.is('checked', checked),
cn.is('disabled', disabled),
cn.is('focused', focused),
cn.has('label', hasLabel),
]"
>
<label :class="[cn.e('wrapper')]">
<input
:name="id"
:class="[cn.e('input')]"
type="checkbox"
:disabled="disabled"
:checked="checked"
@focus="focused = true"
@blur="focused = false"
@change="handleChange"
>
<div :class="[cn.e('inner')]" />
<p :class="[cn.e('label')]">
<slot>{{ label }}</slot>
</p>
</label>
</div>
</template>
<script setup lang="ts">
import { useSlots } from 'vue'
import useClassname from '../../composables/use-classname'
export interface Props {
id: string
value: string | number
label?: string
disabled?: boolean
modelValue?: string | number
required?: boolean
}
defineOptions({
name: 'UiRadio',
modelValue: undefined,
})
const props = withDefaults(defineProps<Props>(), {
disabled: false,
required: false,
})
defineEmits<{
'update:modelValue': [state: boolean | string | number]
focus: []
blur: []
}>()
const slots = useSlots()
const cn = useClassname('ui-radio')
const { checked, focused, hasLabel, handleChange } = useRadio(props, slots)
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,82 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.ui-radio {
$self: &;
--size: 20px;
display: flex;
&__wrapper {
display: inline-flex;
position: relative;
}
&__input {
width: 0;
height: 0;
opacity: 0;
cursor: pointer;
z-index: -1;
margin: 0;
}
&__inner {
--border-color: var(--radio-border-color);
--border-width: 1px;
width: var(--size);
height: var(--size);
border-radius: 50%;
outline: var(--border-width) solid var(--border-color);
outline-offset: calc(var(--border-width) * -1);
font-size: 10px;
cursor: pointer;
background-clip: content-box;
padding: 5px;
transition: .2s ease-out;
transition-property: outline-color, background-color;
#{$self}__input:focus-visible + &,
#{$self}:not(.is-disabled) &:hover {
--border-width: 2px;
}
#{$self}.is-checked & {
background-color: var(--radio-checked-background);
}
#{$self}.is-disabled & {
--border-color: var(--radio-disabled-border-color);
cursor: not-allowed;
}
#{$self}.is-disabled.is-checked & {
background-color: var(--radio-disabled-checked-background);
}
}
&__label {
@include txt-i-m;
margin-left: 8px;
margin-top: 1px;
color: var(--radio-label-color);
#{$self}.is-disabled & {
color: var(--radio-label-disabled-color);
cursor: not-allowed;
}
}
@include element-variant('radio', '', (
'border-color': $clr-grey-400,
'checked-background': $clr-grey-400,
'disabled-border-color': $clr-grey-300,
'disabled-checked-background': $clr-grey-300,
'label-color': $clr-black,
'label-disabled-color': $clr-grey-400
))
}

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import {
Comment,
Fragment,
Text,
cloneVNode,
defineComponent,
h,
withDirectives,
} from 'vue'
// eslint-disable-next-line vue/prefer-import-from-vue
import { isObject } from '@vue/shared'
import type { VNode } from 'vue'
import useClassname from '../../composables/use-classname'
export default defineComponent({
name: 'UiRenderless',
setup(_, { slots, attrs }) {
return () => {
const defaultSlot = slots.default?.(attrs)
if (!defaultSlot)
return null
if (defaultSlot.length > 1)
return null
const firstLegitNode = findFirstLegitChild(defaultSlot)
if (!firstLegitNode)
return null
return withDirectives(cloneVNode(firstLegitNode!, attrs, true), [])
}
},
})
function findFirstLegitChild(node: VNode[] | undefined): VNode | null {
if (!node)
return null
const children = node as VNode[]
for (const child of children) {
/**
* when user uses h(Fragment, [text]) to render plain string,
* this switch case just cannot handle, when the value is primitives
* we should just return the wrapped string
*/
if (isObject(child)) {
switch (child.type) {
case Comment:
continue
case Text:
case 'svg':
return wrapTextContent(child)
case Fragment:
return findFirstLegitChild(child.children as VNode[])
default:
return child
}
}
return wrapTextContent(child)
}
return null
}
function wrapTextContent(s: string | VNode) {
const cn = useClassname('ui-renderless')
return h('span', { class: cn.e('content') }, [s])
}
</script>

View File

@@ -0,0 +1,106 @@
<template>
<div
:class="[
cn.b(),
cn.m(size),
cn.is('focused', focused),
cn.is('disabled', disabled),
]"
>
<label :class="cn.e('wrapper')">
<Component
:is="searchIcon"
:class="cn.e('icon')"
/>
<input
ref="inputRef"
v-model="value"
type="search"
:class="cn.e('input')"
:placeholder="label"
:disabled="disabled"
@focus="focused = true"
@blur="focused = false"
>
<UiButton
v-show="value.length > 0"
size="small"
type="link"
color="secondary"
:class="cn.e('clear')"
:icon="clearIcon"
@click="value = ''"
@mousedown.prevent
/>
</label>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { debounce } from 'lodash-es'
import useClassname from '../../composables/use-classname'
import UiButton from '../button/index.vue'
export interface Props {
label?: string
size?: 'small' | 'medium' | 'large'
disabled?: false
modelValue?: string
}
defineOptions({
name: 'UiSearch',
})
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
disabled: false,
modelValue: '',
})
const emit = defineEmits<{
'update:modelValue': [term: string]
}>()
const cn = useClassname('ui-search')
const inputRef = ref(null)
const value = ref(props.modelValue)
const focused = ref(false)
const searchIcon = computed(() => {
return resolveComponent(props.size === 'small' ? 'ui-icon-s-search' : 'ui-icon-search')
})
const clearIcon = computed(() => {
return props.size === 'small' ? 's-cross' : 'cross'
})
const onInput = debounce(() => {
emit('update:modelValue', value.value)
}, 300)
watch(
() => props.modelValue,
(term) => {
value.value = term
},
)
watch(value, onInput)
function focus() {
inputRef.value?.focus()
}
defineExpose({
focus,
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,125 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.ui-search {
$self: &;
&__wrapper {
--border-color: var(--search-border-color);
--border-width: 1px;
display: flex;
align-items: center;
padding: 8px 16px;
outline: var(--border-width) solid var(--border-color);
outline-offset: calc(var(--border-width) * -1);
color: var(--search-color);
border-radius: 12px;
flex: 1;
#{$self}--large & {
height: 48px;
}
#{$self}--small & {
padding: 8px 12px;
border-radius: 8px;
}
#{$self}:not(.is-disabled) & {
cursor: text;
}
#{$self}:not(.is-disabled) &:hover,
#{$self}.is-focused & {
--border-width: 2px;
}
#{$self}.is-disabled & {
--border-color: var(--search-disabled-border-color);
background: var(--search-disabled-background);
}
}
&__icon {
color: var(--search-icon-color);
margin-right: 8px;
pointer-events: none;
transition: color .2s ease-out;
#{$self}--small & {
margin-right: 4px;
}
#{$self}.is-focused & {
color: var(--search-active-icon-color);
}
}
&__input {
@include txt-i-m;
flex: 1;
width: 100%;
height: 100%;
border: none;
background: none;
outline: none;
padding: 0;
color: var(--search-value-color);
caret-color: var(--search-caret-color);
appearance: none;
&::-webkit-search-decoration,
&::-webkit-search-cancel-button,
&::-webkit-search-results-button,
&::-webkit-search-results-decoration {
-webkit-appearance: none;
}
&::-ms-reveal,
&::-ms-clear {
display: none;
}
&:-webkit-autofill {
-webkit-background-clip: text;
-webkit-text-fill-color: var(--search-color);
}
&::placeholder {
color: var(--search-placeholder-color);
}
#{$self}--small & {
@include txt-r-m;
}
#{$self}.is-disabled & {
color: $clr-grey-400;
}
}
&__clear {
margin-left: 8px;
#{$self}--small & {
margin-left: 4px;
}
}
/* prettier-ignore */
@include element-variant(
'search',
'',
(
'border-color': $clr-grey-300,
'color': $clr-black,
'placeholder-color': $clr-grey-400,
'icon-color': $clr-grey-400,
'active-icon-color': $clr-grey-500,
'disabled-background': $clr-grey-100,
'disabled-border-color': $clr-grey-300
)
);
}

View File

@@ -0,0 +1,460 @@
<template>
<div
ref="rootEl"
:class="[cn.b(), cn.is('active', active), cn.is('filled', isFilled)]"
@keydown.esc="onEscape"
>
<div
ref="wrapperEl"
:class="cn.e('wrapper')"
tabindex="0"
@click="toggleDropdown"
@keydown.enter.space.prevent="toggleDropdown"
>
<p :class="cn.e('label')">
{{ label }}
</p>
<div :class="cn.e('content')">
<!-- <UiCoin -->
<!-- v-if="selectedOptionCoin" -->
<!-- :name="selectedOptionCoin" -->
<!-- :class="cn.e('coin')" -->
<!-- circle -->
<!-- /> -->
<input
ref="controlEl"
:value="selectedOptionLabel"
type="text"
:class="cn.e('control')"
autocomplete="off"
tabindex="-1"
:placeholder="placeholder || $t('ui.select.select_option')"
@keydown.enter.stop.prevent="toggleOption(options[0])"
>
<UiIconSCrossCompact
v-if="clearable && isFilled"
:class="cn.e('clear')"
@click.stop="clear"
/>
</div>
<UiIconChevronDown :class="cn.e('chevron')" />
</div>
<Transition
name="dropdown"
@enter="$emit('opened')"
@after-leave="onAfterLeave"
>
<div
v-if="active"
ref="popperEl"
:class="cn.e('popper')"
:style="floatingStyles"
>
<div
ref="dropdownEl"
role="listbox"
:class="cn.e('dropdown')"
>
<UiSearch
v-if="searchable"
ref="searchInput"
v-model="searchTerm"
:class="cn.e('search')"
:label="$t('ui.search')"
size="small"
/>
<div
ref="itemsEl"
:class="cn.e('items')"
>
<div
ref="scrollerEl"
:class="cn.e('items-scroller')"
>
<div
:style="{
position: 'relative',
height: `${virtualizer?.getTotalSize()}px`,
}"
>
<UiSelectOption
v-for="item in virtualItems"
:key="item.index"
:ref="(el) => virtualizer.measureElement(el?.$el)"
:data-index="item.index"
:label="getOptionLabel(item.option)"
:coin="getOptionCoin(item.option)"
:selected="isOptionSelected(item.option)"
:disabled="isOptionDisabled(item.option)"
:multiple="multiple"
:style="item.styles"
@click="toggleOption(item.option)"
/>
</div>
</div>
</div>
</div>
</div>
</Transition>
<div
v-if="invalid || caption"
:class="[cn.e('bottom')]"
>
<span
v-if="invalid"
:class="[cn.e('validation-message')]"
>
{{ errorMessage }}
</span>
<span
v-else-if="caption"
:class="[cn.e('caption')]"
>{{ caption }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, toRef, watch } from 'vue'
import { isEqual } from 'lodash-es'
// import UiCoin from 'alfabit-ui/components/coin/coin.vue';
import { useField } from 'vee-validate'
import { useI18n } from 'vue-i18n'
import { autoUpdate, useFloating } from '@floating-ui/vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { observeElementRect } from '@tanstack/virtual-core'
import { onClickOutside } from '@vueuse/core'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import { useOverlayScrollbars } from 'overlayscrollbars-vue'
import UiSearch from '../search/index.vue'
import useFuseSearch from '../../composables/use-fuse-search.js'
import useClassname from '../../composables/use-classname'
import UiSelectOption from './option.vue'
import 'overlayscrollbars/overlayscrollbars.css'
import type { Props } from './types'
defineOptions({
name: 'UiSelect',
})
const props = withDefaults(defineProps<Props>(), {
disabled: false,
required: false,
clearable: false,
searchable: false,
multiple: false,
emitValue: false,
mapOptions: false,
})
const emit = defineEmits([
'update:modelValue',
'show',
'closed',
'opened',
'blur',
'focus',
])
const rootEl = ref()
const wrapperEl = ref()
const popperEl = ref()
const dropdownEl = ref()
const controlEl = ref()
const virtualScrollEl = ref()
const scrollbarEl = ref()
const itemsEl = ref()
const scrollerEl = ref()
const active = ref(false)
const searchTerm = ref('')
const cn = useClassname('ui-select')
const { t } = useI18n()
// TODO: Так как позиция всегда фиксированная, может быть убрать useFloating?
const { floatingStyles } = useFloating(wrapperEl, popperEl, {
placement: 'bottom',
whileElementsMounted: autoUpdate,
open: active,
})
const { activate: activateFocusTrap, deactivate: deactivateFocusTrap }
= useFocusTrap(rootEl)
const [initScrollbar, scrollbarInstance] = useOverlayScrollbars({
options: {
scrollbars: {
theme: 'os-small-theme',
},
},
})
onClickOutside(rootEl, () => {
toggleDropdown(false)
})
const isEmptyValue = value => [null, undefined, ''].includes(value)
const {
value: modelValue,
errorMessage,
handleChange,
handleBlur,
validate,
} = useField(
toRef(props, 'id'),
computed(() => ({ required: props.required })),
{
initialValue: !isEmptyValue(props.modelValue)
? props.modelValue
: undefined,
validateOnValueUpdate: false,
syncVModel: true,
},
)
const invalid = computed(() => !props.disabled && !!errorMessage.value)
const localValue = computed(() => {
const isEmpty = isEmptyValue(modelValue.value)
const mapNull = props.mapOptions && !props.multiple
let val = []
if (!isEmpty || mapNull) {
val
= props.multiple && Array.isArray(modelValue.value)
? modelValue.value
: [modelValue.value]
}
if (props.mapOptions) {
const values = val.map((v) => {
return props.options.find(opt => isEqual(getOptionValue(opt), v))
})
return isEmpty && mapNull ? values.filter(v => !!v) : values
}
return val
})
const isFilled = computed(() => localValue.value.length > 0)
const getOptionValue = getPropValueFn(props.optionValue, 'value')
const getOptionLabel = getPropValueFn(props.optionLabel, 'label')
const getOptionCoin = getPropValueFn(
props.optionCoin,
'coin',
!props.forceCoin,
true,
)
function isOptionDisabled(opt) {
return getPropValueFn(props.optionDisabled, 'disabled')(opt) === true
}
const localOptionsValue = computed(() => {
return localValue.value.map(opt => getOptionValue(opt))
})
const optionsLabelMap = computed(() => {
return props.options.map((opt, idx) => ({
index: idx,
value: getOptionLabel(opt),
}))
})
const selectedOptionLabel = computed(() => {
if (props.multiple && localValue.value.length > 1)
return t('ui.select.selected', [localValue.value.length])
return getOptionLabel(localValue.value?.[0])
})
const selectedOptionCoin = computed(() => {
if (props.multiple && localValue.value.length > 1)
return null
return getOptionCoin(localValue.value?.[0])
})
const { result: searchResult } = useFuseSearch(
optionsLabelMap,
{
keys: ['value'],
},
searchTerm,
)
const options = computed(() => {
return searchResult.value.map(opt => props.options[opt.index])
})
const virtualizer = useVirtualizer({
get count() {
return options.value.length
},
getScrollElement: () => scrollerEl.value,
estimateSize: () => 44,
overscan: 5,
initialRect: {
width: 1,
height: 44,
},
observeElementRect: process.server ? () => {} : observeElementRect,
})
const virtualItems = computed(() => {
return virtualizer.value.getVirtualItems().map((virtualItem) => {
return {
...virtualItem,
option: options.value[virtualItem.index],
styles: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
},
}
})
})
watch(options, () => {
virtualizer.value.scrollToOffset(0)
})
watch([itemsEl, scrollerEl], ([itemsEl, scrollerEl]) => {
if (!itemsEl || !scrollerEl)
return
initScrollbar({
target: itemsEl,
elements: {
viewport: scrollerEl,
},
})
})
watch(popperEl, (value) => {
if (value)
activateFocusTrap()
else
deactivateFocusTrap()
})
onMounted(async () => {
if (!isEmptyValue(modelValue.value) && !props.disabled)
await validate()
})
function clear() {
handleChange(props.multiple ? [] : null)
if (active.value)
active.value = false
}
function toggleDropdown(state) {
if (props.disabled)
return
if (typeof state === 'boolean') {
active.value = state
}
else {
if (!popperEl.value || !popperEl.value.contains(document.activeElement))
active.value = !active.value
}
}
function getPropValueFn(
propValue,
defaultVal,
noPrimitives = false,
omitDefault = false,
) {
const val = propValue !== undefined ? propValue : defaultVal
if (typeof val === 'function')
return val
return (opt) => {
if (['string', 'number'].includes(typeof opt))
return noPrimitives ? null : opt
if (omitDefault)
return opt?.[val] ?? null
else
return opt?.[val] ?? opt?.[defaultVal] ?? null
}
}
function isOptionSelected(opt) {
const val = getOptionValue(opt)
return localOptionsValue.value.findIndex(v => isEqual(v, val)) > -1
}
function toggleOption(opt, keepOpen = false) {
if (isOptionDisabled(opt))
return
const optValue = getOptionValue(opt)
if (!props.multiple) {
if (
!isFilled.value
|| !isEqual(getOptionValue(localValue.value[0]), optValue)
)
handleChange(props.emitValue ? optValue : opt)
!keepOpen && toggleDropdown(false)
return
}
if (!isFilled.value) {
handleChange([props.emitValue ? optValue : opt])
return
}
const model = modelValue.value.slice()
const index = localOptionsValue.value.findIndex(v => isEqual(v, optValue))
if (index > -1)
model.splice(index, 1)
else
model.push(props.emitValue ? optValue : opt)
handleChange(model)
}
function onAfterLeave() {
scrollbarInstance()?.destroy()
if (props.searchable)
searchTerm.value = ''
}
function onEscape(event) {
if (active.value) {
event.stopPropagation()
active.value = false
}
}
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div
:class="[cn.b(), cn.is('selected', selected), cn.is('disabled', disabled)]"
:tabindex="disabled ? -1 : 0"
@click="$emit('click')"
@keydown.space="$emit('click')"
@keydown.enter="$emit('click')"
>
<slot>
<!-- <UiCoin v-if="coin" :name="coin" :class="cn.e('coin')" circle /> -->
<span :class="[cn.e('label')]">{{ label }}</span>
<UiIconSCheck
v-if="multiple"
:class="cn.e('checkbox')"
/>
</slot>
</div>
</template>
<script setup lang="ts">
import useClassname from '../../composables/use-classname'
// import UiCoin from 'alfabit-ui/components/coin/coin.vue';
export interface Props {
label: string
disabled?: boolean
selected?: boolean
multiple?: boolean
coin?: string
}
defineOptions({
name: 'UiSelectOption',
})
const props = withDefaults(defineProps<Props>(), {
disabled: false,
selected: false,
multiple: false,
})
defineEmits(['click'])
const cn = useClassname('ui-select-option')
</script>

View File

@@ -0,0 +1,194 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.ui-select {
$self: &;
position: relative;
width: 169px;
&__wrapper {
display: grid;
grid-template-areas: 'label chevron' 'content chevron';
grid-template-columns: 1fr auto;
column-gap: 8px;
align-items: center;
padding: 8px 16px;
border-radius: 12px;
cursor: pointer;
background-color: var(--select-background);
transition: .2s ease-out;
transition-property: background-color, border-radius, box-shadow;
outline: none;
&:focus-visible,
&:hover {
background-color: var(--select-hover-background);
}
&:active {
background-color: var(--select-active-background);
}
#{$self}.is-active & {
border-radius: 12px 12px 0 0;
box-shadow: 0 4px 4px 0 #6C86AD40;
}
}
&__label {
@include txt-s-m;
grid-area: label;
color: var(--select-label-color);
transition: color .2s ease-out;
user-select: none;
#{$self}.is-active &,
#{$self}__wrapper:hover &,
#{$self}__wrapper:focus-visible & {
color: var(--select-label-hover-color);
}
}
&__content {
display: flex;
grid-area: content;
}
&__control {
@include txt-i-m;
background: none;
color: var(--select-color);
padding: 0;
border: none;
width: 100%;
outline: none;
cursor: pointer;
user-select: none;
pointer-events: none;
appearance: none;
&::placeholder {
color: var(--select-color);
}
}
&__clear {
color: var(--select-clear-color);
transition: color .2s ease-out;
&:hover {
color: var(--select-clear-hover-color);
}
}
&__chevron {
color: var(--select-chevron-color);
grid-area: chevron;
}
&__popper {
width: 100%;
z-index: 6500;
}
&__dropdown {
border-radius: 0 0 12px 12px;
overflow: hidden;
background-color: $clr-white;
box-shadow: 0 4px 4px 0 #6C86AD40;
}
&__search {
padding: 8px 16px;
}
&__items-scroller {
max-height: calc(44px * 4);
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
}
}
&__bottom {
@include txt-s-m;
margin-top: 4px;
padding-inline: 16px;
}
&__validation-message {
color: var(--input-validation-message-color);
}
&__caption {
color: var(--input-caption-color);
}
@include element-variant('select', '', (
'label-color': $clr-grey-500,
'label-hover-color': $clr-grey-600,
'color': $clr-black,
'background': $clr-white,
'hover-background': $clr-grey-200,
'active-background': $clr-grey-300,
'chevron-color': $clr-black,
'clear-color': $clr-black,
'clear-hover-color': $clr-cyan-500,
));
}
.ui-select-option {
$self: &;
@include txt-m-m;
display: flex;
align-items: center;
padding: 12px 16px;
background-color: $clr-white;
transition: background-color .2s ease-out;
cursor: pointer;
user-select: none;
outline: none;
&:hover,
&:focus {
background-color: $clr-grey-200;
}
&:active,
&.is-selected {
background-color: $clr-grey-300;
}
&__icon {
margin-right: 8px;
}
&__label {
flex: 1;
}
&__checkbox {
width: 16px;
height: 16px;
border-radius: 4px;
outline: 1px solid $clr-grey-400;
outline-offset: -1px;
transition: .2s ease-out;
transition-property: background-color, outline-color;
pointer-events: none;
color: transparent;
#{$self}.is-selected & {
outline-color: transparent;
background-color: $clr-grey-200;
color: $clr-grey-600;
}
}
}

View File

@@ -0,0 +1,19 @@
export interface Props {
id: string
disabled?: boolean
label: string
placeholder?: string
modelValue?: unknown
required?: boolean
searchable?: boolean
clearable?: boolean
options: unknown[]
optionValue?: string
optionLabel?: string
optionDisabled?: string
optionCoin?: string
emitValue?: boolean
mapOptions?: boolean
multiple?: boolean
caption?: string
}

View File

@@ -0,0 +1,86 @@
<template>
<div
:class="[
cn.b(),
cn.is('timer', !indeterminate && remainingTime > 0),
cn.is('indeterminate', indeterminate),
]"
>
<svg :class="[cn.e('circle-wrapper')]">
<circle
:class="[cn.e('circle')]"
cx="50%"
cy="50%"
r="45%"
/>
<circle
:class="[cn.e('inner')]"
cx="50%"
cy="50%"
r="45%"
/>
</svg>
<div
v-if="showSeconds && remainingTime > 0"
:class="[cn.e('remaining-time')]"
>
{{ remainingTime }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import useClassname from '../../composables/use-classname.js'
export interface Props {
delay?: number
showSeconds?: boolean
}
defineOptions({
name: 'UiSpinner',
})
const props = withDefaults(defineProps<Props>(), {
delay: 0,
showSeconds: false,
})
const emit = defineEmits<{
done: []
}>()
const cn = useClassname('ui-spinner')
const remainingTime = ref(0)
const indeterminate = computed(() => props.delay === 0)
function countdown() {
let startTimestamp: number, elapsedTime: number
const step = (timestamp: number) => {
startTimestamp = startTimestamp || timestamp
elapsedTime = timestamp - startTimestamp
remainingTime.value = Math.ceil(props.delay - elapsedTime / 1000)
if (elapsedTime < props.delay * 1000)
window.requestAnimationFrame(step)
else
emit('done')
}
window.requestAnimationFrame(step)
}
onMounted(() => {
if (props.delay > 0)
countdown()
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,93 @@
.ui-spinner {
--length: calc(2 * 3.14 * (var(--spinner-size, 20px) * 0.45));
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: var(--spinner-size, 20px);
height: var(--spinner-size, 20px);
border-radius: var(--spinner-border-radius, 50%);
pointer-events: none;
&__circle-wrapper {
position: relative;
width: 100%;
height: 100%;
transform: rotateZ(-90deg);
}
&__circle,
&__inner {
fill: none;
stroke-width: 10%;
stroke-linecap: round;
}
&__circle {
stroke: var(--spinner-color, currentColor);
opacity: var(--spinner-circle-opacity, 0.5);
}
&__inner {
stroke: var(--spinner-color, currentColor);
stroke-dasharray: var(--length);
stroke-dashoffset: 0;
}
&__remaining-time {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
font-size: 12px;
stroke: var(--c-text-gray);
transition: 0.5s ease;
color: var(--spinner-color, currentColor);
-webkit-background-clip: initial;
background-clip: initial;
-webkit-text-fill-color: initial;
text-align: center;
}
&.is-timer {
.ui-spinner__inner {
animation: timer v-bind('delay + "s"') linear;
}
}
&.is-indeterminate {
animation: indeterminate var(--spinner-speed, 1.5s) linear infinite;
.ui-spinner__inner {
stroke-dashoffset: calc(var(--length) * 0.75);
}
}
}
@keyframes indeterminate {
from {
-ms-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-webkit-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-ms-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-webkit-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes timer {
from {
stroke-dashoffset: var(--length);
}
to {
stroke-dashoffset: 0;
}
}

View File

@@ -0,0 +1,127 @@
<template>
<div
:class="[
cn.b(),
cn.is('loading', loading),
cn.is('checked', checked),
cn.is('focused', focused),
cn.is('disabled', disabled),
]"
>
<div
:class="cn.e('control')"
@click="switchValue"
>
<input
type="checkbox"
:class="cn.e('input')"
:disabled="disabled"
:checked="checked"
:value="trueValue"
@focus="focused = true"
@blur="focused = false"
@change="handleChange"
>
<div :class="cn.e('action')">
<UiSpinner
v-if="loading && !disabled"
:class="cn.e('spinner')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, toRef } from 'vue'
import { isPromise } from '@vue/shared'
import { useField } from 'vee-validate'
import useClassname from '../../composables/use-classname'
import UiSpinner from '../spinner/index.vue'
export interface Props {
id: string
disabled?: boolean
modelValue?: boolean | string | number
trueValue?: boolean | string | number
falseValue?: boolean | string | number
dummy?: boolean
loading?: boolean
beforeChange?: () => Promise<boolean> | boolean
}
defineOptions({
name: 'UiSwitch',
})
const props = withDefaults(defineProps<Props>(), {
disabled: false,
trueValue: true,
falseValue: false,
dummy: false,
modelValue: undefined,
loading: false,
})
const emit = defineEmits<{
'update:modelValue': [state: boolean | string | number]
change: [state: boolean | string | number]
focus: []
blur: []
}>()
const cn = useClassname('ui-switch')
const { checked, handleChange: fieldHandleChange } = useField(
toRef(props, 'id'),
null,
{
type: 'checkbox',
initialValue: props.modelValue ?? props.falseValue,
checkedValue: props.trueValue,
uncheckedValue: props.falseValue,
syncVModel: true,
},
)
const focused = ref(false)
const handleChange = computed<(e: Event) => void>(() => {
return props.dummy ? handleChangeDummy : fieldHandleChange
})
function handleChangeDummy() {
emit('change', checked.value ? props.falseValue : props.trueValue)
}
async function switchValue(e: Event) {
if (props.disabled || props.loading)
return
const { beforeChange } = props
if (!beforeChange) {
handleChange.value(e)
return
}
const shouldChange = beforeChange()
if (isPromise(shouldChange)) {
try {
const result = await shouldChange
result && handleChange.value(e)
}
catch {}
}
else if (shouldChange) {
handleChange.value(e)
}
}
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,93 @@
@use '../../styles/mixins' as *;
@use '../../styles/variables' as *;
.ui-switch {
$self: &;
display: inline-flex;
&__control {
position: relative;
border-radius: 5px;
background: var(--switch-background);
width: 28px;
height: 10px;
cursor: pointer;
transition: background 0.2s ease;
outline: none;
#{$self}.is-loading & {
cursor: wait;
}
#{$self}.is-disabled & {
cursor: not-allowed;
background-color: var(--switch-disabled-background);
}
}
&__input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
pointer-events: none;
}
&__action {
--action-size: var(--switch-action-size, 16px);
position: absolute;
top: -3px;
left: 0;
width: var(--action-size);
height: var(--action-size);
background: var(--switch-off-action-background);
border-radius: 50%;
transition: .2s ease;
transition-property: background-color, left;
#{$self}.is-focused &,
#{$self}__control:hover & {
background: var(--switch-off-action-hover-background);
}
#{$self}.is-checked & {
background: var(--switch-on-action-background);
left: calc(100% - var(--action-size));
}
#{$self}:not(.is-disabled).is-checked.is-focused &,
#{$self}:not(.is-disabled).is-checked #{$self}__control:hover & {
background-color: var(--switch-on-action-hover-background);
}
#{$self}.is-disabled & {
background-color: var(--switch-off-action-disabled-background);
}
#{$self}.is-disabled.is-checked & {
background-color: var(--switch-on-action-disabled-background);
}
}
&__spinner {
--spinner-size: 12px;
--spinner-color: #{$clr-white};
margin: 2px;
}
/* prettier-ignore */
@include element-variant('switch', '', (
'background': $clr-grey-300,
'disabled-background': $clr-grey-200,
'off-action-background': $clr-grey-400,
'off-action-hover-background': $clr-grey-500,
'off-action-disabled-background': $clr-grey-300,
'on-action-background': $clr-cyan-500,
'on-action-hover-background': $clr-cyan-400,
'on-action-disabled-background': $clr-cyan-300
));
}

View File

@@ -0,0 +1,37 @@
<template>
<div :class="[cn.b(), cn.m(color)]">
<UiSwitcherOption
v-for="(option, index) in options"
:id="id"
:key="index"
:label="option.label"
:value="option.value"
:size="size"
/>
</div>
</template>
<script setup lang="ts">
import UiSwitcherOption from './option.vue'
import type { ModelValue, Props } from './types'
defineOptions({
name: 'UiSwitcher',
})
const props = withDefaults(defineProps<Props>(), {
color: 'blue',
required: false,
size: 'medium',
})
defineEmits<{
'update:modelValue': [state: ModelValue]
}>()
const cn = useClassname('ui-switcher')
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,26 @@
<template>
<UiButton
:class="[cn.b()]"
type="ghost"
color="secondary"
:icon="icon"
:state="checked ? 'active' : undefined"
:size="size"
@click="handleChange"
>
<template v-if="label">
{{ label }}
</template>
</UiButton>
</template>
<script setup lang="ts">
import type { OptionProps } from './types.js'
const props = defineProps<OptionProps>()
const slots = useSlots()
const cn = useClassname('ui-switcher-option')
const { checked, handleChange } = useRadio(props, slots)
</script>

View File

@@ -0,0 +1,23 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.ui-switcher {
display: flex;
padding: 4px;
border-radius: 12px;
gap: 8px;
&--blue {
background-color: $clr-grey-200;
}
&--white {
background-color: $clr-grey-100;
}
}
.ui-switcher-option {
--button-border-radius: 8px;
flex: 1;
}

View File

@@ -0,0 +1,21 @@
import type { Props as ButtonProps } from '../button/index.vue'
import type { UiIcon } from '#build/types/ui/icons'
export type ModelValue = string | number | boolean
export interface OptionProps {
id: string
label?: string
icon?: UiIcon
value: ModelValue
size?: ButtonProps['size']
}
export interface Props {
id: string
options: Pick<OptionProps, 'label' | 'value'>[]
modelValue?: ModelValue
required?: boolean
color?: 'blue' | 'white'
size?: ButtonProps['size']
}

View File

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

View File

@@ -0,0 +1,41 @@
<template>
<div :class="cn.b()">
<UiTabsOption
v-for="(option, index) in options"
:key="index"
:size="size"
v-bind="option"
/>
</div>
</template>
<script setup lang="ts">
import UiTabsOption from './option.vue'
import type { ModelValue, Props } from './types'
import { tabsContextKey } from './constants'
defineOptions({
name: 'UiTabs',
})
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [state: ModelValue]
}>()
const cn = useClassname('ui-tabs')
function handleOptionClick(value: ModelValue) {
emit('update:modelValue', value)
}
provide(tabsContextKey, {
modelValue: toRef(props, 'modelValue'),
handleOptionClick,
})
</script>
<style lang="scss">
@use 'styles';
</style>

View File

@@ -0,0 +1,27 @@
<template>
<UiButton
:class="cn.b()"
:type="active ? 'filled' : 'ghost'"
:color="active ? 'primary' : 'secondary'"
:icon="icon"
:size="size"
@click="tabs.handleOptionClick(value)"
>
<template v-if="label">
{{ label }}
</template>
</UiButton>
</template>
<script setup lang="ts">
import type { OptionProps } from './types.js'
import { tabsContextKey } from './constants'
const props = defineProps<OptionProps>()
const tabs = inject(tabsContextKey)!
const cn = useClassname('ui-tabs-option')
const active = computed(() => tabs.modelValue.value === props.value)
</script>

View File

@@ -0,0 +1,7 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.ui-tabs {
display: flex;
gap: 8px;
}

View File

@@ -0,0 +1,22 @@
import type { Props as ButtonProps } from '../button/index.vue'
import type { UiIcon } from '#build/types/ui/icons'
export type ModelValue = string | number
export interface OptionProps {
label?: string
icon?: UiIcon
value: ModelValue
size?: ButtonProps['size']
}
export interface Props {
options: Omit<OptionProps, 'size'>[]
modelValue?: ModelValue
size?: ButtonProps['size']
}
export interface TabsContext {
modelValue?: ModelValue
handleOptionClick: (value: ModelValue) => void
}