initial
This commit is contained in:
5
layers/ui/components/accordion/constants.ts
Normal file
5
layers/ui/components/accordion/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
import type { AccordionContext } from './types'
|
||||
|
||||
export const accordionContextKey: InjectionKey<AccordionContext>
|
||||
= Symbol('UI_ACCORDION')
|
||||
92
layers/ui/components/accordion/index.vue
Normal file
92
layers/ui/components/accordion/index.vue
Normal 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>
|
||||
79
layers/ui/components/accordion/item.vue
Normal file
79
layers/ui/components/accordion/item.vue
Normal 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>
|
||||
96
layers/ui/components/accordion/styles.scss
Normal file
96
layers/ui/components/accordion/styles.scss
Normal 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;
|
||||
}
|
||||
85
layers/ui/components/accordion/transition.vue
Normal file
85
layers/ui/components/accordion/transition.vue
Normal 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>
|
||||
14
layers/ui/components/accordion/types.ts
Normal file
14
layers/ui/components/accordion/types.ts
Normal 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>
|
||||
}
|
||||
73
layers/ui/components/alert/index.vue
Normal file
73
layers/ui/components/alert/index.vue
Normal 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>
|
||||
83
layers/ui/components/alert/styles.scss
Normal file
83
layers/ui/components/alert/styles.scss
Normal 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,
|
||||
));
|
||||
}
|
||||
16
layers/ui/components/alert/types.ts
Normal file
16
layers/ui/components/alert/types.ts
Normal 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
|
||||
}
|
||||
39
layers/ui/components/badge/index.vue
Normal file
39
layers/ui/components/badge/index.vue
Normal 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>
|
||||
47
layers/ui/components/badge/styles.scss
Normal file
47
layers/ui/components/badge/styles.scss
Normal 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,
|
||||
));
|
||||
}
|
||||
16
layers/ui/components/badge/types.ts
Normal file
16
layers/ui/components/badge/types.ts
Normal 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
|
||||
}
|
||||
145
layers/ui/components/button/index.vue
Normal file
145
layers/ui/components/button/index.vue
Normal 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>
|
||||
240
layers/ui/components/button/styles.scss
Normal file
240
layers/ui/components/button/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
216
layers/ui/components/calendar/index.vue
Normal file
216
layers/ui/components/calendar/index.vue
Normal 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>
|
||||
140
layers/ui/components/calendar/styles.scss
Normal file
140
layers/ui/components/calendar/styles.scss
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
74
layers/ui/components/checkbox/index.vue
Normal file
74
layers/ui/components/checkbox/index.vue
Normal 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>
|
||||
98
layers/ui/components/checkbox/styles.scss
Normal file
98
layers/ui/components/checkbox/styles.scss
Normal 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
|
||||
))
|
||||
}
|
||||
209
layers/ui/components/code-input/index.vue
Normal file
209
layers/ui/components/code-input/index.vue
Normal 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>
|
||||
1
layers/ui/components/coin/available-coins.json
Normal file
1
layers/ui/components/coin/available-coins.json
Normal 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"]
|
||||
117
layers/ui/components/coin/index.vue
Normal file
117
layers/ui/components/coin/index.vue
Normal 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>
|
||||
23
layers/ui/components/coin/styles.scss
Normal file
23
layers/ui/components/coin/styles.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
74
layers/ui/components/copy-button/index.vue
Normal file
74
layers/ui/components/copy-button/index.vue
Normal 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>
|
||||
52
layers/ui/components/copy-button/styles.scss
Normal file
52
layers/ui/components/copy-button/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
layers/ui/components/dropdown/constants.ts
Normal file
5
layers/ui/components/dropdown/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
import type { DropdownContext } from './types'
|
||||
|
||||
export const dropdownContextKey: InjectionKey<DropdownContext>
|
||||
= Symbol('UI_DROPDOWN')
|
||||
187
layers/ui/components/dropdown/index.vue
Normal file
187
layers/ui/components/dropdown/index.vue
Normal 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>
|
||||
42
layers/ui/components/dropdown/item.vue
Normal file
42
layers/ui/components/dropdown/item.vue
Normal 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>
|
||||
7
layers/ui/components/dropdown/separator.vue
Normal file
7
layers/ui/components/dropdown/separator.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<li :class="cn.b()" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const cn = useClassname('ui-dropdown-separator')
|
||||
</script>
|
||||
79
layers/ui/components/dropdown/styles.scss
Normal file
79
layers/ui/components/dropdown/styles.scss
Normal 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;
|
||||
}
|
||||
|
||||
3
layers/ui/components/dropdown/types.ts
Normal file
3
layers/ui/components/dropdown/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface DropdownContext {
|
||||
handleItemClick: () => void
|
||||
}
|
||||
329
layers/ui/components/input/index.vue
Normal file
329
layers/ui/components/input/index.vue
Normal 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>
|
||||
139
layers/ui/components/input/styles.scss
Normal file
139
layers/ui/components/input/styles.scss
Normal 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
|
||||
))
|
||||
}
|
||||
113
layers/ui/components/notification/notification.vue
Normal file
113
layers/ui/components/notification/notification.vue
Normal 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>
|
||||
134
layers/ui/components/notification/notify.ts
Normal file
134
layers/ui/components/notification/notify.ts
Normal 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
|
||||
27
layers/ui/components/notification/types.ts
Normal file
27
layers/ui/components/notification/types.ts
Normal 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[]
|
||||
69
layers/ui/components/plain-table/index.vue
Normal file
69
layers/ui/components/plain-table/index.vue
Normal 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>
|
||||
36
layers/ui/components/progress-bar/index.vue
Normal file
36
layers/ui/components/progress-bar/index.vue
Normal 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>
|
||||
33
layers/ui/components/progress-bar/styles.scss
Normal file
33
layers/ui/components/progress-bar/styles.scss
Normal 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,
|
||||
));
|
||||
|
||||
}
|
||||
29
layers/ui/components/qr-code/index.vue
Normal file
29
layers/ui/components/qr-code/index.vue
Normal 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>
|
||||
70
layers/ui/components/radio/index.vue
Normal file
70
layers/ui/components/radio/index.vue
Normal 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>
|
||||
82
layers/ui/components/radio/styles.scss
Normal file
82
layers/ui/components/radio/styles.scss
Normal 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
|
||||
))
|
||||
}
|
||||
69
layers/ui/components/renderless/index.vue
Normal file
69
layers/ui/components/renderless/index.vue
Normal 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>
|
||||
106
layers/ui/components/search/index.vue
Normal file
106
layers/ui/components/search/index.vue
Normal 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>
|
||||
125
layers/ui/components/search/styles.scss
Normal file
125
layers/ui/components/search/styles.scss
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
460
layers/ui/components/select/index.vue
Normal file
460
layers/ui/components/select/index.vue
Normal 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>
|
||||
48
layers/ui/components/select/option.vue
Normal file
48
layers/ui/components/select/option.vue
Normal 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>
|
||||
194
layers/ui/components/select/styles.scss
Normal file
194
layers/ui/components/select/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
layers/ui/components/select/types.ts
Normal file
19
layers/ui/components/select/types.ts
Normal 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
|
||||
}
|
||||
86
layers/ui/components/spinner/index.vue
Normal file
86
layers/ui/components/spinner/index.vue
Normal 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>
|
||||
93
layers/ui/components/spinner/styles.scss
Normal file
93
layers/ui/components/spinner/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
127
layers/ui/components/switch/index.vue
Normal file
127
layers/ui/components/switch/index.vue
Normal 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>
|
||||
93
layers/ui/components/switch/styles.scss
Normal file
93
layers/ui/components/switch/styles.scss
Normal 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
|
||||
));
|
||||
}
|
||||
37
layers/ui/components/switcher/index.vue
Normal file
37
layers/ui/components/switcher/index.vue
Normal 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>
|
||||
26
layers/ui/components/switcher/option.vue
Normal file
26
layers/ui/components/switcher/option.vue
Normal 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>
|
||||
23
layers/ui/components/switcher/styles.scss
Normal file
23
layers/ui/components/switcher/styles.scss
Normal 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;
|
||||
}
|
||||
21
layers/ui/components/switcher/types.ts
Normal file
21
layers/ui/components/switcher/types.ts
Normal 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']
|
||||
}
|
||||
5
layers/ui/components/tabs/constants.ts
Normal file
5
layers/ui/components/tabs/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
import type { TabsContext } from './types'
|
||||
|
||||
export const tabsContextKey: InjectionKey<TabsContext>
|
||||
= Symbol('UI_TABS')
|
||||
41
layers/ui/components/tabs/index.vue
Normal file
41
layers/ui/components/tabs/index.vue
Normal 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>
|
||||
27
layers/ui/components/tabs/option.vue
Normal file
27
layers/ui/components/tabs/option.vue
Normal 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>
|
||||
7
layers/ui/components/tabs/styles.scss
Normal file
7
layers/ui/components/tabs/styles.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.ui-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
22
layers/ui/components/tabs/types.ts
Normal file
22
layers/ui/components/tabs/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user