Components renaming (#225)

* refactor: eslint config adjusted for better diff's

* refactor: stricter linting
 + dependencies updated

* refactoring: paragraph component
 - component
 - docs

* refactoring: heading component
 - component
 - docs

* Update docs/components/heading.md

Co-authored-by: Ilya Artamonov <ilya.sosidka@gmail.com>

* refactoring: link component
 - component
 - docs

* refactoring: image component
 - component
 - docs

* refactoring: alert component
 - component
 - docs

* refactoring: avatar component
 - component
 - docs

* refactoring: removed unnecessary code
    - component names come from the file name

* refactoring: breadcrumb component
 - component
 - docs

* refactoring: accordion component
 - component
 - docs

* refactoring: buttom component
 - component
 - docs

* refactoring: badge component
 - component
 - docs

* refactoring: card component
 - component
 - docs

* refactoring: order of components in docs updated

* refactoring: unnecessary semicolons removed

* refactoring: button group component
 - component
 - docs

* refactoring: carousel component
 - component
 - docs

* refactoring: dropdown component
 - component
 - docs

* refactoring: footer component
 - component
 - docs

* refactoring:list-group component
 - component
 - docs

* refactoring: modal component
 - component
 - docs

* refactoring: navbar component
 - component
 - docs

* refactoring: pagination component
 - component
 - docs

* refactoring: progress component
 - component
 - docs

* refactoring: rating component
 - component
 - docs

* refactoring: spinner component
 - component
 - docs

* refactoring: table component
 - component
 - docs

* refactoring: tabs component
 - component
 - docs

* feat: Updated pagination examples

* lint: Lister fixes

* feat: Sidebar component and some fixes

* feat: Input component

* feat: Some fixes

* feat: Some fixes

* chore: update deps

* refactor: removed old Modal component

* refactor: radio component and some fixes

* fix: fixed path error

* refactor: Range component

* refactoring: timeline component
 - component
 - docs

* refactor: Select component

* refactoring: toast component
 - component
 - docs

* refactoring: tooltip component
 - component
 - docs

* refactoring: sidebar component
 - component
 - docs

* refactoring: input component
 - component
 - docs

* refactoring: fileInput component
 - component
 - docs

* refactoring: select component
 - component
 - docs

* refactoring: textarea component
 - component
 - docs

* refactoring: checkbox component
 - component
 - docs

* refactoring: radio component
 - component
 - docs

* refactoring: toggle component
 - component
 - docs

* refactoring: range component
 - component
 - docs

* local configs linted

* documentation quick start updated

* flowbite-themable refactored to fit new linters and style guide

* random linter fixes

* refactoring: toast-provider component
 - component
 - docs

* final linter fixes

* lint: Linter fixes

* fix: Fixed types

* fix: Fixed card component

* docs: Updated card examples

* fix: Fixed tabs

* refactor: Heading component refactoring

* Fwb rename - few fixes after component review (#237)

* fix: button documentation

* fix: model type in range examples

* chore: Toast marked as WIP

---------

Co-authored-by: Sqrcz <naorniakowski@slashlab.pl>
Co-authored-by: Sqrcz <naorniakowski@gmail.com>
This commit is contained in:
Ilya Artamonov
2023-10-23 18:23:14 +03:00
committed by GitHub
parent ea6fcf1a4c
commit d316cf3a12
600 changed files with 16349 additions and 10239 deletions

View File

@@ -1,71 +0,0 @@
<template>
<div class="relative">
<img v-if="img && !imageError" :class="avatarClasses" :src="img" :alt="alt" @error="setImageError">
<div v-else :class="avatarPlaceholderWrapperClasses">
<svg v-if="!initials && !hasPlaceholder" :class="avatarPlaceholderClasses" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path>
</svg>
<div v-if="!initials && hasPlaceholder" :class="avatarPlaceholderClasses">
<slot name="placeholder" />
</div>
<div v-else :class="avatarPlaceholderInitialsClasses">{{ initials }}</div>
<span v-if="status" :class="avatarDotClasses" :data-pos="statusPosition"></span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, toRefs, useSlots } from 'vue'
import type { PropType } from 'vue'
import type { AvatarSize, AvatarStatus, AvatarStatusPosition } from './types'
import { useAvatarClasses } from '@/components/Avatar/composables/useAvatarClasses'
const imageError = ref(false)
function setImageError() {
imageError.value = true
}
const slots = useSlots()
const hasPlaceholder = computed(() => slots.placeholder)
const props = defineProps({
alt: {
type: String,
default: 'Avatar',
},
bordered: {
type: Boolean,
default: false,
},
img: {
type: String,
default: '',
},
rounded: {
type: Boolean,
default: false,
},
size: {
type: String as PropType<AvatarSize>,
default: 'md',
},
stacked: {
type: Boolean,
default: false,
},
status: {
type: String as PropType<AvatarStatus>,
default: null,
},
statusPosition: {
type: String as PropType<AvatarStatusPosition>,
default: 'top-right',
},
initials: {
type: String,
default: null,
},
})
const { avatarClasses, avatarDotClasses, avatarPlaceholderClasses, avatarPlaceholderWrapperClasses, avatarPlaceholderInitialsClasses } = useAvatarClasses(toRefs(props))
</script>

View File

@@ -1,17 +0,0 @@
<template>
<a class="relative flex justify-center items-center w-10 h-10 text-xs font-medium text-white bg-gray-700 rounded-full border-2 border-white hover:bg-gray-600 dark:border-gray-800" :href="href"
>+{{ total }}</a
>
</template>
<script setup lang="ts">
defineProps({
total: {
type: Number,
default: 1,
},
href: {
type: String,
default: '#',
},
})
</script>

View File

@@ -1,112 +0,0 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import classNames from 'classnames'
import type { AvatarSize, AvatarStatus, AvatarStatusPosition, AvatarType, avatarDotIndicatorPositionClasses } from '@/components/Avatar/types'
const avatarSizeClasses: Record<AvatarSize, string> = {
xs: 'w-6 h-6',
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-20 h-20',
xl: 'w-36 h-36',
}
const avatarTypeClasses: Record<AvatarType, string> = {
default: 'rounded',
rounded: 'rounded-full',
}
const avatarBorderedClasses = 'ring-2 ring-gray-300 dark:ring-gray-500 p-1'
const avatarStatusDotDefaultClasses = 'absolute h-3.5 w-3.5 rounded-full border-2 border-white dark:border-gray-800'
const avatarStatusDotClasses: Record<AvatarStatus, string> = {
away: 'bg-gray-400',
busy: 'bg-yellow-400',
offline: 'bg-red-400',
online: 'bg-green-400',
}
const avatarStatusDotPositionClasses: Record<avatarDotIndicatorPositionClasses, string> = {
'top-right-rounded': 'top-0 -right-0.5',
'top-right-default': '-top-1.5 -right-1.5',
'top-left-rounded': 'top-0 left-0',
'top-left-default': 'top-0 left-0 transform -translate-y-1/2 -translate-x-1/2',
'bottom-right-rounded': 'bottom-0 -right-0.5',
'bottom-right-default': 'bottom-0 -right-1.5 translate-y-1/2',
'bottom-left-rounded': 'bottom-0 left-0',
'bottom-left-default': '-bottom-1.5 left-0 transform -translate-x-1/2 ',
}
const avatarPlaceholderDefaultClasses = 'absolute w-auto h-auto text-gray-400'
const avatarPlaceholderWrapperDefaultClasses = 'inline-flex overflow-hidden relative justify-center items-center bg-gray-100 dark:bg-gray-600'
const avatarPlaceholderInitialsDefaultClasses = 'font-medium text-gray-600 dark:text-gray-300'
const avatarPlaceholderSizes = {
xs: 'bottom-0',
sm: 'bottom-0',
md: '-bottom-1',
lg: '-bottom-2',
xl: '-bottom-4',
}
export type UseAvatarClassesProps = {
status: Ref<AvatarStatus>
bordered: Ref<boolean>
img: Ref<string>
alt: Ref<string>
rounded: Ref<boolean>
size: Ref<AvatarSize>
stacked: Ref<boolean>
statusPosition: Ref<AvatarStatusPosition>
}
export function useAvatarClasses(props: UseAvatarClassesProps): {
avatarClasses: Ref<string>
avatarDotClasses: Ref<string>
avatarPlaceholderClasses: Ref<string>
avatarPlaceholderWrapperClasses: Ref<string>
avatarPlaceholderInitialsClasses: Ref<string>
} {
const avatarClasses = computed<string>(() => {
return classNames(
avatarSizeClasses[props.size.value],
avatarTypeClasses[props.rounded.value ? 'rounded' : 'default'],
props.bordered.value ? avatarBorderedClasses : '',
props.stacked.value ? 'border-2 border-white dark:border-gray-800' : '',
)
})
const avatarDotClasses = computed<string>(() => {
const avatarType = `${props.statusPosition.value}-${props.rounded.value ? 'rounded' : 'default'}`
return classNames(
avatarStatusDotDefaultClasses,
avatarStatusDotClasses[props.status.value],
avatarStatusDotPositionClasses[avatarType as avatarDotIndicatorPositionClasses],
)
})
const avatarPlaceholderClasses = computed<string>(() => {
return classNames(
avatarPlaceholderDefaultClasses,
avatarPlaceholderSizes[props.size.value],
)
})
const avatarPlaceholderWrapperClasses = computed<string>(() => {
return classNames(
avatarPlaceholderWrapperDefaultClasses,
avatarSizeClasses[props.size.value],
avatarTypeClasses[props.rounded.value ? 'rounded' : 'default'],
)
})
const avatarPlaceholderInitialsClasses = computed<string>(() => {
return classNames(
avatarPlaceholderInitialsDefaultClasses,
)
})
// TODO: Avatar Initials
return {
avatarClasses,
avatarDotClasses,
avatarPlaceholderClasses,
avatarPlaceholderWrapperClasses,
avatarPlaceholderInitialsClasses,
}
}

View File

@@ -1,35 +0,0 @@
<template>
<li class="inline-flex items-center">
<slot name="arrow-icon">
<svg v-if="!home" class="w-6 h-6 text-gray-400 mr-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
</slot>
<component :is="breadcrumbElementType" :href="href" :class="breadcrumbItemClasses">
<slot name="home-icon">
<svg v-if="home" class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
</slot>
<slot name="default" />
</component>
</li>
</template>
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import { useBreadcrumbItemClasses } from '@/components/Breadcrumb/composables/useBreadcrumbItemClasses'
const props = defineProps({
href: {
type: String,
default: null,
},
home: {
type: Boolean,
default: false,
},
})
const breadcrumbElementType = computed(() => {
return props.href ? 'a' : 'span'
})
const { breadcrumbItemClasses } = useBreadcrumbItemClasses(toRefs(props))
</script>

View File

@@ -1,35 +0,0 @@
import type { BreadcrumbType } from '../types'
import type { Ref } from 'vue'
import { computed } from 'vue'
import classNames from 'classnames'
const breadcrumbDefaultClasses = 'inline-flex items-center space-x-1 md:space-x-3'
const breadcrumbWrapperVariantClasses: Record<BreadcrumbType, string> = {
default: 'flex',
solid: 'flex px-5 py-3 text-gray-700 border border-gray-200 rounded-lg bg-gray-50 dark:bg-gray-800 dark:border-gray-700',
}
export type useBreadcrumbProps = {
solid: Ref<boolean>
}
export function useBreadcrumbClasses(props: useBreadcrumbProps): {
breadcrumbClasses: Ref<string>
breadcrumbWrapperClasses: Ref<string>
} {
const breadcrumbClasses = computed<string>(() => {
return classNames(
breadcrumbDefaultClasses,
)
})
const breadcrumbWrapperClasses = computed<string>(() => {
return classNames(
breadcrumbWrapperVariantClasses[props.solid.value ? 'solid' : 'defauilt' as BreadcrumbType],
)
})
return {
breadcrumbClasses,
breadcrumbWrapperClasses,
}
}

View File

@@ -1,26 +0,0 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import classNames from 'classnames'
const breadcrumbItemDefaultClasses = 'ml-1 inline-flex items-center text-sm font-medium dark:text-gray-400'
const breadcrumbItemLinkClasses = 'text-gray-700 hover:text-gray-900 dark:hover:text-white'
const breadcrumbSpanClasses = 'text-gray-500'
export type useBreadcrumbItemProps = {
href: Ref<string>
home: Ref<boolean>
}
export function useBreadcrumbItemClasses(props: useBreadcrumbItemProps): {
breadcrumbItemClasses: Ref<string>
} {
const breadcrumbItemClasses = computed<string>(() => {
return classNames(
breadcrumbItemDefaultClasses,
props.href.value ? breadcrumbItemLinkClasses : breadcrumbSpanClasses,
)
})
return {
breadcrumbItemClasses,
}
}

View File

@@ -1,44 +0,0 @@
import type { ButtonGradient, ButtonSize, ButtonVariant } from '../types'
import type { SpinnerColor, SpinnerSize } from '../../Spinner/types'
import type { Ref } from 'vue'
import { computed } from 'vue'
export type UseButtonSpinnerProps = {
outline: Ref<boolean>
size: Ref<ButtonSize>
color: Ref<ButtonVariant>
gradient: Ref<ButtonGradient | null>
}
export function useButtonSpinner(props: UseButtonSpinnerProps): { size: Ref<SpinnerSize>, color: Ref<SpinnerColor> } {
const btnSizeSpinnerSizeMap: Record<ButtonSize, SpinnerSize> = {
lg: '5', md: '4', sm: '3', xl: '6', xs: '2.5',
}
const size = computed<SpinnerSize>(() => {
return btnSizeSpinnerSizeMap[props.size.value]
})
const color = computed<SpinnerColor>(() => {
if(!props.outline.value) return 'white'
if(props.gradient.value) {
if(props.gradient.value.includes('purple')) return 'purple'
else if(props.gradient.value.includes('blue')) return 'blue'
else if(props.gradient.value.includes('pink')) return 'pink'
else if(props.gradient.value.includes('red')) return 'red'
return 'white'
}
if(['alternative', 'dark', 'light'].includes(props.color.value)) {
return 'white'
} else if(props.color.value === 'default') {
return 'blue'
}
return props.color.value as SpinnerColor
})
return {
size,
color,
}
}

View File

@@ -1,45 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '../Button.vue'
describe('Button', () => {
it('renders correct text', () => {
const wrapper = mount(Button, { props: {}, slots: { default: 'test' } })
expect(wrapper.text()).toBe('test')
})
it('provides correct classes for default color button', () => {
const defaultButtonClasses = [
'text-white',
'bg-blue-700',
'hover:bg-blue-800',
'focus:ring-4',
'focus:ring-blue-300',
'font-medium',
'rounded-lg',
'dark:bg-blue-600',
'dark:hover:bg-blue-700',
'focus:outline-none',
'dark:focus:ring-blue-800',
'text-sm',
'px-4',
'py-2',
]
const wrapper = mount(Button, { props: { color: 'default' } })
const classes = wrapper.classes()
defaultButtonClasses.forEach(cl => expect(classes).toContain(cl))
})
it('provides correct classes for XL size', () => {
const xlButtonSizeClasses = [
'text-base', 'px-6', 'py-3',
]
const wrapper = mount(Button, { props: { size: 'xl' } })
const classes = wrapper.classes()
xlButtonSizeClasses.forEach(cl => expect(classes).toContain(cl))
})
})

View File

@@ -1,34 +0,0 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import type { CardsVariant } from '../types'
export type UseCardsClassesProps = {
variant: Ref<CardsVariant>
}
export function useCardsClasses(props: UseCardsClassesProps): {
cardClasses: Ref<string>,
horizontalImageClasses: Ref<string>
} {
const cardClasses = computed(() => {
if(props.variant.value === 'default')
return 'block max-w-sm bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700'
else if(props.variant.value === 'image')
return 'max-w-sm bg-white rounded-lg border border-gray-200 shadow-md dark:bg-gray-800 dark:border-gray-700'
else if(props.variant.value === 'horizontal')
return 'flex flex-col items-center bg-white rounded-lg border shadow-md md:flex-row md:max-w-xl hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700'
return ''
})
const horizontalImageClasses = computed(() => {
if(props.variant.value === 'horizontal')
return 'object-cover w-full h-96 rounded-t-lg md:h-auto md:w-48 md:rounded-none md:rounded-l-lg'
return ''
})
return {
cardClasses,
horizontalImageClasses,
}
}

View File

@@ -1,124 +0,0 @@
<template>
<div id="default-carousel" class="relative">
<!-- Carousel wrapper -->
<div class="overflow-hidden relative h-56 rounded-lg sm:h-64 xl:h-80 2xl:h-96">
<!-- Item 1 -->
<!-- duration-700 ease-in-out-->
<div :class="index === currentPicture ? 'z-30' : 'z-0'"
v-for="(picture, index) in pictures" :key="index" class="absolute inset-0 -translate-y-0">
<img :src="picture.src" class="block absolute top-1/2 left-1/2 w-full -translate-x-1/2 -translate-y-1/2" :alt="picture.alt">
</div>
</div>
<!-- Slider indicators -->
<div v-if="indicators" class="flex absolute bottom-5 left-1/2 z-30 space-x-3 -translate-x-1/2">
<button v-for="(picture, index) in pictures" :key="index" type="button" :class="index === currentPicture ? 'bg-white' : 'bg-white/50'" class="w-3 h-3 rounded-full bg-white" aria-current="false" :aria-label="'Slide ' + index" @click.prevent="slideTo(index)"></button>
</div>
<!-- Slider controls -->
<button v-if="controls" @click.prevent="previousPicture" type="button" class="flex absolute top-0 left-0 z-30 justify-center items-center px-4 h-full cursor-pointer group focus:outline-none" data-carousel-prev>
<span class="inline-flex justify-center items-center w-8 h-8 rounded-full sm:w-10 sm:h-10 bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-5 h-5 text-white sm:w-6 sm:h-6 dark:text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
<span class="hidden">Previous</span>
</span>
</button>
<button v-if="controls" @click.prevent="nextPicture" type="button" class="flex absolute top-0 right-0 z-30 justify-center items-center px-4 h-full cursor-pointer group focus:outline-none" data-carousel-next>
<span class="inline-flex justify-center items-center w-8 h-8 rounded-full sm:w-10 sm:h-10 bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg class="w-5 h-5 text-white sm:w-6 sm:h-6 dark:text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
<span class="hidden">Next</span>
</span>
</button>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import type { PropType } from 'vue'
import type { PictureItem } from '@/components/Carousel/types'
const props = defineProps({
pictures: {
type: Array as PropType<PictureItem[]>,
default() {
return [
{
'src': 'https://flowbite.com/docs/images/carousel/carousel-1.svg',
'alt': 'Picture 1',
},
{
'src': 'https://flowbite.com/docs/images/carousel/carousel-2.svg',
'alt': 'Picture 2',
},
{
'src': 'https://flowbite.com/docs/images/carousel/carousel-3.svg',
'alt': 'Picture 3',
},
]
},
},
indicators: {
type: Boolean,
default: true,
},
controls: {
type: Boolean,
default: true,
},
slide: {
type: Boolean,
default: false,
},
slideInterval: {
type: Number,
default: 3000,
},
animation: {
type: Boolean,
default: false,
},
})
const currentPicture = ref(0)
const direction = ref('')
const interval = ref()
const automaticSlide = () => {
interval.value = setInterval(function() {
nextPicture()
}, props.slideInterval)
}
const resetInterval = () => {
clearInterval(interval.value)
automaticSlide()
}
const slideTo = (index: number) => {
currentPicture.value = index
resetInterval()
}
const nextPicture = () => {
if (currentPicture.value !== props.pictures.length - 1) {
currentPicture.value ++
} else {
currentPicture.value = 0
}
direction.value = 'right'
resetInterval()
}
const previousPicture = () => {
if (currentPicture.value !== 0) {
currentPicture.value --
} else {
currentPicture.value = props.pictures.length -1
}
direction.value = 'left'
resetInterval()
}
onMounted(() => {
if (props.slide) {
automaticSlide()
}
})
</script>

View File

@@ -1,4 +0,0 @@
export type PictureItem = {
src: string,
alt?: string
}

View File

@@ -1,41 +0,0 @@
/* to right */
.to-right-enter-active,
.to-right-leave-to { opacity: 0; transform: translateX(-10px) }
.to-right-leave,
.to-right-enter-to { opacity: 1; transform: translateX(0) }
.to-right-enter-active,
.to-right-leave-active { transition: all 250ms }
/* to left */
.to-left-enter-active,
.to-left-leave-to { opacity: 0; transform: translateX(10px) }
.to-left-leave,
.to-left-enter-to { opacity: 1; transform: translateX(0) }
.to-left-enter-active,
.to-left-leave-active { transition: all 250ms }
/* to top */
.to-top-enter-active,
.to-top-leave-to { opacity: 0; transform: translateY(10px) }
.to-top-leave,
.to-top-enter-to { opacity: 1; transform: translateY(0) }
.to-top-enter-active,
.to-top-leave-active { transition: all 250ms }
/* to bottom */
.to-bottom-enter-active,
.to-bottom-leave-to { opacity: 0; transform: translateY(-10px) }
.to-bottom-leave,
.to-bottom-enter-to { opacity: 1; transform: translateY(0) }
.to-bottom-enter-active,
.to-bottom-leave-active { transition: all 250ms }

View File

@@ -1,78 +0,0 @@
<template>
<div class="inline-flex relative" ref="wrapper">
<div class="inline-flex items-center">
<slot-listener @click="onToggle">
<slot name="trigger">
<Button>
{{ text }}
<template #suffix>
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</template>
</Button>
</slot>
</slot-listener>
</div>
<transition :name="transitionName">
<div ref="content" v-if="visible" :style="contentStyles" :class="[contentClasses]">
<slot-listener @click="onHide">
<slot />
</slot-listener>
</div>
</transition>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, toRef } from 'vue'
import type { DropdownPlacement } from './types'
import { useDropdownClasses } from './composables/useDropdownClasses'
import Button from '../Button/Button.vue'
import { onClickOutside } from '@vueuse/core'
import SlotListener from '@/components/utils/SlotListener/SlotListener.vue'
const visible = ref(false)
const onHide = () => (visible.value = false)
const onToggle = () => (visible.value = !visible.value)
const props = withDefaults(
defineProps<{
placement: DropdownPlacement
text: string
transition: string
}>(),
{
placement: 'bottom',
text: '',
transition: '',
},
)
const placementTransitionMap: Record<DropdownPlacement, string> = {
bottom: 'to-bottom',
left: 'to-left',
right: 'to-right',
top: 'to-top',
}
const transitionName = computed(() => {
if (props.transition === null) return placementTransitionMap[props.placement]
return props.transition
})
const content = ref<HTMLDivElement>()
const wrapper = ref<HTMLDivElement>()
const { contentClasses, contentStyles } = useDropdownClasses({
placement: toRef(props, 'placement'),
visible,
contentRef: content,
})
onClickOutside(wrapper, () => {
if (!visible.value) return
visible.value = false
})
</script>
<style scoped src="./Dropdown.css"></style>

View File

@@ -1,67 +0,0 @@
import { computed, nextTick, ref, watch } from 'vue'
import type { Ref } from 'vue'
import classNames from 'classnames'
import type { DropdownPlacement } from '../types'
const defaultDropdownClasses = 'absolute z-10 bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700'
const defaultGapInPx = 8
const placementDropdownClasses: Record<DropdownPlacement, string> = {
bottom: '',
left: 'top-0',
right: 'top-0',
top: '',
}
export type UseDropdownClassesProps = {
placement: Ref<DropdownPlacement>
contentRef: Ref<HTMLDivElement | undefined>
visible: Ref<boolean>
}
const placementCalculators: Record<DropdownPlacement, (rect: DOMRect) => string> = {
bottom(rect: DOMRect): string {
return `bottom: -${rect.height + defaultGapInPx}px;`
},
left(rect: DOMRect): string {
return `left: -${rect.width + defaultGapInPx}px;`
},
right(rect: DOMRect): string {
return `right: -${rect.width + defaultGapInPx}px;`
},
top(rect: DOMRect): string {
return `top: -${rect.height + defaultGapInPx}px;`
},
}
export function useDropdownClasses(props: UseDropdownClassesProps): {
contentClasses: Ref<string>
contentStyles: Ref<string>
} {
watch(props.visible, (value: boolean) => {
if(value) nextTick(() => calculatePlacementClasses())
})
const placementStyles = ref('')
const calculatePlacementClasses = () => {
const boundingRect = props.contentRef.value?.getBoundingClientRect()
if(!boundingRect) return placementStyles.value = ''
placementStyles.value = placementCalculators[props.placement.value](boundingRect)
}
const contentClasses = computed(() => {
return classNames(
defaultDropdownClasses,
placementDropdownClasses[props.placement.value],
)
})
return {
contentClasses,
contentStyles: placementStyles,
}
}

View File

@@ -1,40 +0,0 @@
import { simplifyTailwindClasses } from '@/utils/simplifyTailwindClasses'
import { computed } from 'vue'
const fileInpDefaultClasses =
'block w-full text-sm text-gray-900 border-[1px] border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400'
const fileInpLabelClasses = 'block mb-2 text-sm font-medium text-gray-900 dark:text-white'
const fileInpDropzoneClasses =
'flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:hover:bg-bray-800 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600'
const fileDropzoneWrapClasses = 'flex flex-col items-center justify-center pt-5 pb-6'
const fileDropzoneDefaultTextClasses = '!-mb-2 text-sm text-gray-500 dark:text-gray-400'
export function useFileInputClasses(size: string) {
const fileInpClasses = computed(() => {
return simplifyTailwindClasses(fileInpDefaultClasses, 'text-' + size)
})
const labelClasses = computed(() => {
return fileInpLabelClasses
})
const dropzoneLabelClasses = computed(() => {
return fileInpDropzoneClasses
})
const dropzoneWrapClasses = computed(() => {
return fileDropzoneWrapClasses
})
const dropzoneTextClasses = computed(() => {
return fileDropzoneDefaultTextClasses
})
return {
fileInpClasses,
labelClasses,
dropzoneLabelClasses,
dropzoneWrapClasses,
dropzoneTextClasses,
}
}

View File

@@ -3,9 +3,11 @@
<slot />
</div>
</template>
<script lang="ts" setup>
import { nanoid } from 'nanoid'
import { useAccordionState } from '@/components/Accordion/composables/useAccordionState'
import { useAccordionState } from './composables/useAccordionState'
interface AccordionProps {
alwaysOpen?: boolean
openFirstItem?: boolean

View File

@@ -1,8 +1,8 @@
<template>
<div ref="content">
<div
v-if="isLoaded"
:class="contentClasses"
v-if="isLoaded"
:class="contentClasses"
>
<slot />
</div>
@@ -10,9 +10,9 @@
</template>
<script lang="ts" setup>
import { useAccordionContentClasses } from '@/components/Accordion/composables/useAccordionContentClasses'
import { onMounted, ref } from 'vue'
import type { ComputedRef } from 'vue'
import { type ComputedRef, onMounted, ref } from 'vue'
import { useAccordionContentClasses } from './composables/useAccordionContentClasses'
const isLoaded = ref(false)
const content = ref()
let contentClasses: ComputedRef

View File

@@ -1,18 +1,32 @@
<template>
<div ref="header">
<button v-if="isLoaded" type="button" @click="toggleItem" :class="headerClasses">
<button
v-if="isLoaded"
type="button"
:class="headerClasses"
@click="toggleItem"
>
<span class="w-full"><slot /></span>
<svg data-accordion-icon :class="arrowClasses" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"></path>
<svg
data-accordion-icon
:class="arrowClasses"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
</button>
</div>
</template>
<script lang="ts" setup>
import { useAccordionState } from '@/components/Accordion/composables/useAccordionState'
import { computed, onMounted, ref, type ComputedRef } from 'vue'
import { useAccordionHeaderClasses } from '@/components/Accordion/composables/useAccordionHeaderClasses'
import { computed, type ComputedRef, onMounted, ref } from 'vue'
import { useAccordionState } from './composables/useAccordionState'
import { useAccordionHeaderClasses } from './composables/useAccordionHeaderClasses'
const isLoaded = ref(false)
const header = ref()
@@ -24,7 +38,7 @@ const accordionState = computed(() => accordionsStates[accordionId.value])
const panelState = computed(() => accordionState.value.panels[panelId.value])
let headerClasses: ComputedRef, arrowClasses: ComputedRef
function commonToggleItem() {
function commonToggleItem () {
const isSelectedVisible = panelState.value.isVisible
for (const panelIndex in accordionState.value.panels) {
const panel = accordionState.value.panels[panelIndex]
@@ -32,10 +46,10 @@ function commonToggleItem() {
else panel.isVisible = !isSelectedVisible
}
}
function alwaysOpenToggleItem() {
function alwaysOpenToggleItem () {
panelState.value.isVisible = !panelState.value.isVisible
}
function toggleItem() {
function toggleItem () {
if (accordionState.value.alwaysOpen) return alwaysOpenToggleItem()
commonToggleItem()
}

View File

@@ -1,13 +1,16 @@
<template>
<div :data-panel-id="panelId" ref="panel">
<slot v-if="accordionId"></slot>
<div
ref="panel"
:data-panel-id="panelId"
>
<slot v-if="accordionId" />
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useAccordionState } from '@/components/Accordion/composables/useAccordionState'
import { nanoid } from 'nanoid'
import { useAccordionState } from './composables/useAccordionState'
const { accordionsStates } = useAccordionState()

View File

@@ -1,9 +1,10 @@
import { computed, type Ref } from 'vue'
import { useAccordionState } from '@/components/Accordion/composables/useAccordionState'
import { useAccordionState } from './useAccordionState'
import { twMerge } from 'tailwind-merge'
const baseContentClasses = 'p-5 border border-gray-200 dark:border-gray-700 dark:bg-gray-900'
export function useAccordionContentClasses(contentRef: Ref) {
export function useAccordionContentClasses (contentRef: Ref) {
const accordionId = computed(() => contentRef.value.parentElement.parentElement.dataset.accordionId)
const panelId = computed(() => contentRef.value.parentElement.dataset.panelId)
const { accordionsStates } = useAccordionState()

View File

@@ -1,11 +1,12 @@
import { computed, type Ref } from 'vue'
import { useAccordionState } from '@/components/Accordion/composables/useAccordionState'
import { useAccordionState } from './useAccordionState'
import { twMerge } from 'tailwind-merge'
const baseHeaderClasses =
'flex items-center p-5 w-full font-medium text-left text-gray-500 border border-gray-200 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-800 dark:border-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
const baseArrowClasses = 'w-6 h-6 shrink-0'
export function useAccordionHeaderClasses(headerRef: Ref) {
export function useAccordionHeaderClasses (headerRef: Ref) {
const accordionId = computed(() => headerRef.value.parentElement.parentElement.dataset.accordionId)
const panelId = computed(() => headerRef.value.parentElement.dataset.panelId)
const { accordionsStates } = useAccordionState()

View File

@@ -1,12 +1,15 @@
import { onBeforeMount, onBeforeUnmount, reactive } from 'vue'
import type { tState } from '@/components/Accordion/types'
import type { tState } from '../types'
interface AccordionProps {
alwaysOpen?: boolean
openFirstItem?: boolean
flush?: boolean
}
const accordionsStates = reactive<tState>({})
export function useAccordionState(
export function useAccordionState (
id?: string,
options?: AccordionProps,
): {
@@ -15,7 +18,7 @@ export function useAccordionState(
onBeforeMount(() => {
if (!id) return
accordionsStates[id] = {
id: id,
id,
flush: options?.flush ?? false,
alwaysOpen: options?.alwaysOpen ?? false,
openFirstItem: options?.openFirstItem ?? true,

View File

@@ -1,12 +1,15 @@
export type tAccordionMode = 'flush' | 'alwaysOpen' | 'default'
export type tAccordionPanel = {
order: number
id: string
isVisible: boolean
}
type tAccordionPanels = {
[key: string]: tAccordionPanel
}
type tStateElement = {
id: string
flush: boolean
@@ -14,6 +17,7 @@ type tStateElement = {
openFirstItem: boolean
panels: tAccordionPanels
}
export type tState = {
[key: string]: tStateElement
}

View File

@@ -1,23 +1,57 @@
<template>
<div v-if="visible" v-bind="$attrs" :class="wrapperClasses" role="alert">
<div
v-if="visible"
v-bind="$attrs"
:class="wrapperClasses"
role="alert"
>
<div class="flex items-center">
<slot v-if="icon || $slots.icon" name="icon">
<svg class="flex-shrink-0 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
<slot
v-if="icon || $slots.icon"
name="icon"
>
<svg
class="flex-shrink-0 w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
/>
</svg>
</slot>
<slot name="title"></slot>
<slot name="title" />
</div>
<slot name="default" :on-close-click="onCloseClick" />
<slot name="close-icon" :on-close-click="onCloseClick">
<button v-if="closable" type="button" :class="closeBtnClasses" aria-label="Close" @click="onCloseClick">
<slot
name="default"
:on-close-click="onCloseClick"
/>
<slot
name="close-icon"
:on-close-click="onCloseClick"
>
<button
v-if="closable"
type="button"
:class="closeBtnClasses"
aria-label="Close"
@click="onCloseClick"
>
<span class="sr-only">Dismiss</span>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<svg
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
/>
</svg>
</button>
</slot>
@@ -25,8 +59,8 @@
</template>
<script lang="ts" setup>
import { ref, useAttrs } from 'vue'
import type { AlertType } from './types'
import { twMerge } from 'tailwind-merge'
import type { AlertType } from './types'
interface IAlertProps {
type?: AlertType
@@ -34,9 +68,11 @@ interface IAlertProps {
icon?: boolean
border?: boolean
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<IAlertProps>(), {
type: 'info',
closable: false,
@@ -51,9 +87,7 @@ defineSlots<{
title: any
/* eslint-enable @typescript-eslint/no-explicit-any */
}>()
const emits = defineEmits<{
(e: 'close'): void
}>()
const emits = defineEmits<{(e: 'close'): void}>()
const attrs = useAttrs()
const alertTextClasses: Record<AlertType, string> = {
@@ -103,7 +137,7 @@ const wrapperClasses = twMerge(
)
const visible = ref(true)
function onCloseClick() {
function onCloseClick () {
emits('close')
visible.value = false
}

View File

@@ -1 +1 @@
export type AlertType = 'info' | 'danger' | 'success' | 'warning' | 'dark'
export type AlertType = 'info' | 'danger' | 'success' | 'warning' | 'dark'

View File

@@ -0,0 +1,108 @@
<template>
<div class="relative">
<img
v-if="img && !imageError"
:alt="alt"
:class="avatarClasses"
:src="img"
@error="setImageError"
>
<div
v-else
:class="avatarPlaceholderWrapperClasses"
>
<svg
v-if="!initials && !hasPlaceholder"
:class="avatarPlaceholderClasses"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
fill-rule="evenodd"
/>
</svg>
<div
v-if="!initials && hasPlaceholder"
:class="avatarPlaceholderClasses"
>
<slot name="placeholder" />
</div>
<div
v-else
:class="avatarPlaceholderInitialsClasses"
>
{{ initials }}
</div>
<span
v-if="status"
:class="avatarDotClasses"
:data-pos="statusPosition"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, type PropType, ref, toRefs, useSlots } from 'vue'
import type { AvatarSize, AvatarStatus, AvatarStatusPosition } from './types'
import { useAvatarClasses } from '@/components/FwbAvatar/composables/useAvatarClasses'
const imageError = ref(false)
function setImageError () {
imageError.value = true
}
const slots = useSlots()
const hasPlaceholder = computed(() => slots.placeholder)
const props = defineProps({
alt: {
type: String,
default: 'Avatar',
},
bordered: {
type: Boolean,
default: false,
},
img: {
type: String,
default: '',
},
rounded: {
type: Boolean,
default: false,
},
size: {
type: String as PropType<AvatarSize>,
default: 'md',
},
stacked: {
type: Boolean,
default: false,
},
status: {
type: String as PropType<AvatarStatus>,
default: null,
},
statusPosition: {
type: String as PropType<AvatarStatusPosition>,
default: 'top-right',
},
initials: {
type: String,
default: null,
},
})
const {
avatarClasses,
avatarDotClasses,
avatarPlaceholderClasses,
avatarPlaceholderInitialsClasses,
avatarPlaceholderWrapperClasses,
} = useAvatarClasses(toRefs(props))
</script>

View File

@@ -0,0 +1,19 @@
<template>
<a
class="relative flex justify-center items-center w-10 h-10 text-xs font-medium text-white bg-gray-700 rounded-full border-2 border-white hover:bg-gray-600 dark:border-gray-800"
:href="href"
>+{{ total }}</a>
</template>
<script setup lang="ts">
defineProps({
total: {
type: Number,
default: 1,
},
href: {
type: String,
default: '#',
},
})
</script>

View File

@@ -0,0 +1,113 @@
import { computed, type Ref } from 'vue'
import type {
avatarDotIndicatorPositionClasses,
AvatarSize,
AvatarStatus,
AvatarStatusPosition,
AvatarType,
} from '@/components/FwbAvatar/types'
import classNames from 'classnames'
const avatarSizeClasses: Record<AvatarSize, string> = {
xs: 'w-6 h-6',
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-20 h-20',
xl: 'w-36 h-36',
}
const avatarTypeClasses: Record<AvatarType, string> = {
default: 'rounded',
rounded: 'rounded-full',
}
const avatarBorderedClasses = 'ring-2 ring-gray-300 dark:ring-gray-500 p-1'
const avatarStatusDotDefaultClasses = 'absolute h-3.5 w-3.5 rounded-full border-2 border-white dark:border-gray-800'
const avatarStatusDotClasses: Record<AvatarStatus, string> = {
away: 'bg-gray-400',
busy: 'bg-yellow-400',
offline: 'bg-red-400',
online: 'bg-green-400',
}
const avatarStatusDotPositionClasses: Record<avatarDotIndicatorPositionClasses, string> = {
'top-right-rounded': 'top-0 -right-0.5',
'top-right-default': '-top-1.5 -right-1.5',
'top-left-rounded': 'top-0 left-0',
'top-left-default': 'top-0 left-0 transform -translate-y-1/2 -translate-x-1/2',
'bottom-right-rounded': 'bottom-0 -right-0.5',
'bottom-right-default': 'bottom-0 -right-1.5 translate-y-1/2',
'bottom-left-rounded': 'bottom-0 left-0',
'bottom-left-default': '-bottom-1.5 left-0 transform -translate-x-1/2 ',
}
const avatarPlaceholderDefaultClasses = 'absolute w-auto h-auto text-gray-400'
const avatarPlaceholderWrapperDefaultClasses = 'inline-flex overflow-hidden relative justify-center items-center bg-gray-100 dark:bg-gray-600'
const avatarPlaceholderInitialsDefaultClasses = 'font-medium text-gray-600 dark:text-gray-300'
const avatarPlaceholderSizes = {
xs: 'bottom-0',
sm: 'bottom-0',
md: '-bottom-1',
lg: '-bottom-2',
xl: '-bottom-4',
}
export type UseAvatarClassesProps = {
status: Ref<AvatarStatus>
bordered: Ref<boolean>
img: Ref<string>
alt: Ref<string>
rounded: Ref<boolean>
size: Ref<AvatarSize>
stacked: Ref<boolean>
statusPosition: Ref<AvatarStatusPosition>
}
export function useAvatarClasses (props: UseAvatarClassesProps): {
avatarClasses: Ref<string>
avatarDotClasses: Ref<string>
avatarPlaceholderClasses: Ref<string>
avatarPlaceholderWrapperClasses: Ref<string>
avatarPlaceholderInitialsClasses: Ref<string>
} {
const avatarClasses = computed<string>(() => {
return classNames(
avatarSizeClasses[props.size.value],
avatarTypeClasses[props.rounded.value ? 'rounded' : 'default'],
props.bordered.value ? avatarBorderedClasses : '',
props.stacked.value ? 'border-2 border-white dark:border-gray-800' : '',
)
})
const avatarDotClasses = computed<string>(() => {
const avatarType = `${props.statusPosition.value}-${props.rounded.value ? 'rounded' : 'default'}`
return classNames(
avatarStatusDotDefaultClasses,
avatarStatusDotClasses[props.status.value],
avatarStatusDotPositionClasses[avatarType as avatarDotIndicatorPositionClasses],
)
})
const avatarPlaceholderClasses = computed<string>(() => {
return classNames(
avatarPlaceholderDefaultClasses,
avatarPlaceholderSizes[props.size.value],
)
})
const avatarPlaceholderWrapperClasses = computed<string>(() => {
return classNames(
avatarPlaceholderWrapperDefaultClasses,
avatarSizeClasses[props.size.value],
avatarTypeClasses[props.rounded.value ? 'rounded' : 'default'],
)
})
const avatarPlaceholderInitialsClasses = computed<string>(() => {
return classNames(
avatarPlaceholderInitialsDefaultClasses,
)
})
// TODO: Avatar Initials
return {
avatarClasses,
avatarDotClasses,
avatarPlaceholderClasses,
avatarPlaceholderInitialsClasses,
avatarPlaceholderWrapperClasses,
}
}

View File

@@ -1,13 +1,18 @@
<template>
<component :is="wrapperType" :class="badgeClasses" :href="href">
<component
:is="wrapperType"
:class="badgeClasses"
:href="href"
>
<slot name="icon" />
<slot name="default" />
</component>
</template>
<script lang="ts" setup>
import { computed, useSlots } from 'vue'
import type { BadgeType, BadgeSize } from './types'
import { useBadgeClasses } from '@/components/Badge/composables/useBadgeClasses'
import type { BadgeSize, BadgeType } from './types'
import { useBadgeClasses } from './composables/useBadgeClasses'
interface IBadgeProps {
type?: BadgeType

View File

@@ -1,4 +1,4 @@
import type { BadgeType, BadgeSize } from '../types'
import type { BadgeSize, BadgeType } from '../types'
import { computed, type Ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
@@ -42,7 +42,7 @@ export type UseBadgeClassesOptions = {
isContentEmpty: Ref<boolean>
}
export function useBadgeClasses(
export function useBadgeClasses (
props: UseBadgeClassesProps,
options: UseBadgeClassesOptions,
): {
@@ -56,7 +56,7 @@ export function useBadgeClasses(
props.href ? '' : badgeTextClasses[props.type],
props.href ? badgeLinkClasses : '',
options.isContentEmpty.value ? onlyIconClasses : defaultBadgeClasses,
attrs.class as string,
attrs.class as string,
)
})
return {

View File

@@ -1,13 +1,17 @@
<template>
<nav :class="breadcrumbWrapperClasses" aria-label="Breadcrumb">
<nav
:class="breadcrumbWrapperClasses"
aria-label="Breadcrumb"
>
<ol :class="breadcrumbClasses">
<slot name="default" />
</ol>
</nav>
</template>
<script lang="ts" setup>
import { toRefs } from 'vue'
import { useBreadcrumbClasses } from '@/components/Breadcrumb/composables/useBreadcrumbClasses'
import { useBreadcrumbClasses } from './composables/useBreadcrumbClasses'
const props = defineProps({
solid: {
@@ -16,5 +20,5 @@ const props = defineProps({
},
})
const { breadcrumbWrapperClasses, breadcrumbClasses } = useBreadcrumbClasses(toRefs(props))
const { breadcrumbClasses, breadcrumbWrapperClasses } = useBreadcrumbClasses(toRefs(props))
</script>

View File

@@ -0,0 +1,55 @@
<template>
<li class="inline-flex items-center">
<slot name="arrow-icon">
<svg
v-if="!home"
class="w-6 h-6 text-gray-400 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
><path
clip-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
fill-rule="evenodd"
/></svg>
</slot>
<component
:is="breadcrumbElementType"
:class="breadcrumbItemClasses"
:href="href"
>
<slot name="home-icon">
<svg
v-if="home"
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" /></svg>
</slot>
<slot name="default" />
</component>
</li>
</template>
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import { useBreadcrumbItemClasses } from './composables/useBreadcrumbItemClasses'
const props = defineProps({
href: {
type: String,
default: null,
},
home: {
type: Boolean,
default: false,
},
})
const breadcrumbElementType = computed(() => {
return props.href ? 'a' : 'span'
})
const { breadcrumbItemClasses } = useBreadcrumbItemClasses(toRefs(props))
</script>

View File

@@ -0,0 +1,28 @@
import { computed, type Ref } from 'vue'
import classNames from 'classnames'
import type { BreadcrumbType } from '../types'
const breadcrumbDefaultClasses = 'inline-flex items-center space-x-1 md:space-x-3'
const breadcrumbWrapperVariantClasses: Record<BreadcrumbType, string> = {
default: 'flex',
solid: 'flex px-5 py-3 text-gray-700 border border-gray-200 rounded-lg bg-gray-50 dark:bg-gray-800 dark:border-gray-700',
}
export type useBreadcrumbProps = {
solid: Ref<boolean>
}
export function useBreadcrumbClasses (props: useBreadcrumbProps): {
breadcrumbClasses: Ref<string>
breadcrumbWrapperClasses: Ref<string>
} {
const breadcrumbClasses = computed<string>(() => classNames(breadcrumbDefaultClasses))
const breadcrumbWrapperClasses = computed<string>(() => classNames(
breadcrumbWrapperVariantClasses[props.solid.value ? 'solid' : 'defauilt' as BreadcrumbType],
))
return {
breadcrumbClasses,
breadcrumbWrapperClasses,
}
}

View File

@@ -0,0 +1,24 @@
import { computed, type Ref } from 'vue'
import classNames from 'classnames'
const breadcrumbItemDefaultClasses = 'ml-1 inline-flex items-center text-sm font-medium dark:text-gray-400'
const breadcrumbItemLinkClasses = 'text-gray-700 hover:text-gray-900 dark:hover:text-white'
const breadcrumbSpanClasses = 'text-gray-500'
export type useBreadcrumbItemProps = {
href: Ref<string>
home: Ref<boolean>
}
export function useBreadcrumbItemClasses (props: useBreadcrumbItemProps): {
breadcrumbItemClasses: Ref<string>
} {
const breadcrumbItemClasses = computed<string>(() => classNames(
breadcrumbItemDefaultClasses,
props.href.value ? breadcrumbItemLinkClasses : breadcrumbSpanClasses,
))
return {
breadcrumbItemClasses,
}
}

View File

@@ -1,37 +1,83 @@
<template>
<component :is="buttonComponent" :disabled="buttonComponent === 'button' && disabled" :class="wrapperClasses" :[linkAttr]="href">
<div v-if="!isOutlineGradient && ($slots.prefix || loadingPrefix)" class="mr-2">
<component
:is="buttonComponent"
:class="wrapperClasses"
:[linkAttr]="href"
:disabled="buttonComponent === 'button' && disabled"
>
<div
v-if="!isOutlineGradient && ($slots.prefix || loadingPrefix)"
class="mr-2"
>
<!--automatically add mr class if slot provided or loading -->
<spinner :color="spinnerColor" :size="spinnerSize" v-if="loadingPrefix" />
<slot name="prefix" v-else />
<fwb-spinner
v-if="loadingPrefix"
:color="spinnerColor"
:size="spinnerSize"
/>
<slot
v-else
name="prefix"
/>
</div>
<span :class="spanClasses">
<span v-if="isOutlineGradient && ($slots.prefix || loadingPrefix)" class="mr-2">
<span
v-if="isOutlineGradient && ($slots.prefix || loadingPrefix)"
class="mr-2"
>
<!--if outline gradient - need to place slots inside span -->
<spinner :color="spinnerColor" :size="spinnerSize" v-if="loadingPrefix" />
<slot name="prefix" v-else />
<fwb-spinner
v-if="loadingPrefix"
:color="spinnerColor"
:size="spinnerSize"
/>
<slot
v-else
name="prefix"
/>
</span>
<slot />
<span v-if="isOutlineGradient && ($slots.suffix || loadingSuffix)" class="ml-2">
<span
v-if="isOutlineGradient && ($slots.suffix || loadingSuffix)"
class="ml-2"
>
<!--if outline gradient - need to place slots inside span -->
<spinner :color="spinnerColor" :size="spinnerSize" v-if="loadingSuffix" />
<slot name="suffix" v-else />
<fwb-spinner
v-if="loadingSuffix"
:color="spinnerColor"
:size="spinnerSize"
/>
<slot
v-else
name="suffix"
/>
</span>
</span>
<div v-if="!isOutlineGradient && ($slots.suffix || loadingSuffix)" class="ml-2">
<div
v-if="!isOutlineGradient && ($slots.suffix || loadingSuffix)"
class="ml-2"
>
<!--automatically add ml class if slot provided or loading -->
<spinner :color="spinnerColor" :size="spinnerSize" v-if="loadingSuffix" />
<slot name="suffix" v-else />
<fwb-spinner
v-if="loadingSuffix"
:color="spinnerColor"
:size="spinnerSize"
/>
<slot
v-else
name="suffix"
/>
</div>
</component>
</template>
<script lang="ts" setup>
import { computed, resolveComponent, toRefs } from 'vue'
import Spinner from '../Spinner/Spinner.vue'
import FwbSpinner from '../FwbSpinner/FwbSpinner.vue'
import { useButtonClasses } from './composables/useButtonClasses'
import { useButtonSpinner } from './composables/useButtonSpinner'
import type { ButtonGradient, ButtonMonochromeGradient, ButtonSize, ButtonVariant } from './types'

View File

@@ -1,7 +1,6 @@
import type { Ref } from 'vue'
import { computed, useSlots } from 'vue'
import type { ButtonDuotoneGradient, ButtonGradient, ButtonMonochromeGradient, ButtonSize, ButtonVariant } from '../types'
import { computed, type Ref, useSlots } from 'vue'
import { twMerge } from 'tailwind-merge'
import type { ButtonDuotoneGradient, ButtonGradient, ButtonMonochromeGradient, ButtonSize, ButtonVariant } from '../types'
export type ButtonClassMap<T extends string> = { hover: Record<T, string>; default: Record<T, string> }
@@ -100,20 +99,13 @@ const buttonGradientClasses: ButtonClassMap<ButtonGradient> = {
const buttonOutlineGradientClasses: ButtonClassMap<ButtonDuotoneGradient> = {
default: {
'cyan-blue':
'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-cyan-500 to-blue-500 dark:text-white focus:ring-4 focus:outline-none focus:ring-cyan-200 dark:focus:ring-cyan-800',
'green-blue':
'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-green-400 to-blue-600 dark:text-white focus:ring-4 focus:outline-none focus:ring-green-200 dark:focus:ring-green-800',
'pink-orange':
'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-pink-500 to-orange-400 dark:text-white focus:ring-4 focus:outline-none focus:ring-pink-200 dark:focus:ring-pink-800',
'purple-blue':
'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-600 to-blue-500 dark:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 dark:focus:ring-blue-800',
'purple-pink':
'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-500 to-pink-500 dark:text-white focus:ring-4 focus:outline-none focus:ring-purple-200 dark:focus:ring-purple-800',
'red-yellow':
'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-red-200 via-red-300 to-yellow-200 dark:text-white focus:ring-4 focus:outline-none focus:ring-red-100 dark:focus:ring-red-400',
'teal-lime':
'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-teal-300 to-lime-300 dark:text-white focus:ring-4 focus:outline-none focus:ring-lime-200 dark:focus:ring-lime-800',
'cyan-blue': 'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-cyan-500 to-blue-500 dark:text-white focus:ring-4 focus:outline-none focus:ring-cyan-200 dark:focus:ring-cyan-800',
'green-blue': 'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-green-400 to-blue-600 dark:text-white focus:ring-4 focus:outline-none focus:ring-green-200 dark:focus:ring-green-800',
'pink-orange': 'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-pink-500 to-orange-400 dark:text-white focus:ring-4 focus:outline-none focus:ring-pink-200 dark:focus:ring-pink-800',
'purple-blue': 'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-600 to-blue-500 dark:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 dark:focus:ring-blue-800',
'purple-pink': 'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-500 to-pink-500 dark:text-white focus:ring-4 focus:outline-none focus:ring-purple-200 dark:focus:ring-purple-800',
'red-yellow': 'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-red-200 via-red-300 to-yellow-200 dark:text-white focus:ring-4 focus:outline-none focus:ring-red-100 dark:focus:ring-red-400',
'teal-lime': 'relative inline-flex items-center justify-center overflow-hidden font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-teal-300 to-lime-300 dark:text-white focus:ring-4 focus:outline-none focus:ring-lime-200 dark:focus:ring-lime-800',
},
hover: {
'cyan-blue': 'group-hover:from-cyan-500 group-hover:to-blue-500 hover:text-white',
@@ -168,7 +160,7 @@ export type UseButtonClassesProps = {
const simpleGradients = ['blue', 'green', 'cyan', 'teal', 'lime', 'red', 'pink', 'purple']
const alternativeColors = ['alternative', 'light']
export function useButtonClasses(props: UseButtonClassesProps): { wrapperClasses: Ref<string>; spanClasses: Ref<string> } {
export function useButtonClasses (props: UseButtonClassesProps): { wrapperClasses: Ref<string>; spanClasses: Ref<string> } {
const slots = useSlots()
const sizeClasses = computed(() => {

View File

@@ -0,0 +1,51 @@
import { computed, type Ref } from 'vue'
import type { ButtonGradient, ButtonSize, ButtonVariant } from '../types'
import type { SpinnerColor, SpinnerSize } from './../../FwbSpinner/types'
export type UseButtonSpinnerProps = {
outline: Ref<boolean>
size: Ref<ButtonSize>
color: Ref<ButtonVariant>
gradient: Ref<ButtonGradient | null>
}
export function useButtonSpinner (props: UseButtonSpinnerProps): { size: Ref<SpinnerSize>, color: Ref<SpinnerColor> } {
const btnSizeSpinnerSizeMap: Record<ButtonSize, SpinnerSize> = {
xs: '2.5',
sm: '3',
md: '4',
lg: '5',
xl: '6',
}
const size = computed<SpinnerSize>(() => btnSizeSpinnerSizeMap[props.size.value])
const color = computed<SpinnerColor>(() => {
if (!props.outline.value) return 'white'
if (props.gradient.value) {
if (props.gradient.value.includes('purple')) {
return 'purple'
} else if (props.gradient.value.includes('blue')) {
return 'blue'
} else if (props.gradient.value.includes('pink')) {
return 'pink'
} else if (props.gradient.value.includes('red')) {
return 'red'
}
return 'white'
}
if (['alternative', 'dark', 'light'].includes(props.color.value)) {
return 'white'
} else if (props.color.value === 'default') {
return 'blue'
}
return props.color.value as SpinnerColor
})
return {
color,
size,
}
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import FwbButton from '../FwbButton.vue'
describe('FwbButton', () => {
it('renders correct text', () => {
const wrapper = mount(FwbButton, { props: {}, slots: { default: 'test' } })
expect(wrapper.text()).toBe('test')
})
it('provides correct classes for default color button', () => {
const defaultButtonClasses = [
'text-white',
'bg-blue-700',
'hover:bg-blue-800',
'focus:ring-4',
'focus:ring-blue-300',
'font-medium',
'rounded-lg',
'dark:bg-blue-600',
'dark:hover:bg-blue-700',
'focus:outline-none',
'dark:focus:ring-blue-800',
'text-sm',
'px-4',
'py-2',
]
const wrapper = mount(FwbButton, { props: { color: 'default' } })
const classes = wrapper.classes()
defaultButtonClasses.forEach(cl => expect(classes).toContain(cl))
})
it('provides correct classes for XL size', () => {
const xlButtonSizeClasses = [
'text-base', 'px-6', 'py-3',
]
const wrapper = mount(FwbButton, { props: { size: 'xl' } })
const classes = wrapper.classes()
xlButtonSizeClasses.forEach(cl => expect(classes).toContain(cl))
})
})

View File

@@ -2,4 +2,4 @@ export type ButtonMonochromeGradient = 'blue' | 'green' | 'cyan' | 'teal' | 'lim
export type ButtonDuotoneGradient = 'purple-blue' | 'cyan-blue' | 'green-blue' | 'purple-pink' | 'pink-orange' | 'teal-lime' | 'red-yellow'
export type ButtonGradient = ButtonDuotoneGradient | ButtonMonochromeGradient
export type ButtonVariant = 'default' | 'alternative' | 'dark' | 'light' | 'green' | 'red' | 'yellow' | 'purple' | 'pink' | 'blue'
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'

View File

@@ -1,8 +1,12 @@
<template>
<div class="btn-group inline-flex rounded-md shadow-sm" role="group">
<slot />
<div
class="btn-group inline-flex rounded-md shadow-sm"
role="group"
>
<slot />
</div>
</template>
<!-- TODO: maybe use provide/inject to control styles -->
<style>
@tailwind components;
@@ -18,4 +22,4 @@
@apply rounded-r-lg;
}
}
</style>
</style>

View File

@@ -1,14 +1,24 @@
<template>
<component :is="wrapperType" :href="href" :class="cardClasses">
<img v-if="imgSrc" class="rounded-t-lg" :class="horizontalImageClasses" :src="imgSrc" :alt="imgAlt"/>
<component
:is="wrapperType"
:class="cardClasses"
:href="href"
>
<img
v-if="imgSrc"
:alt="imgAlt"
:class="horizontalImageClasses"
:src="imgSrc"
class="rounded-t-lg"
>
<div>
<slot />
</div>
</component>
</template>
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import type { PropType } from 'vue'
import { computed, type PropType, toRefs } from 'vue'
import { useCardsClasses } from './composables/useCardClasses'
import type { CardsVariant } from './types'
@@ -33,5 +43,4 @@ const props = defineProps({
const { cardClasses, horizontalImageClasses } = useCardsClasses(toRefs(props))
const wrapperType = computed(() => props.href ? 'a' : 'div')
</script>

View File

@@ -0,0 +1,33 @@
import { computed, type Ref } from 'vue'
import type { CardsVariant } from '../types'
export type UseCardsClassesProps = {
variant: Ref<CardsVariant>
}
export function useCardsClasses (props: UseCardsClassesProps): {
cardClasses: Ref<string>,
horizontalImageClasses: Ref<string>
} {
const cardClasses = computed(() => {
if (props.variant.value === 'default') {
return 'block max-w-sm bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700'
} else if (props.variant.value === 'image') {
return 'max-w-sm bg-white rounded-lg border border-gray-200 shadow-md dark:bg-gray-800 dark:border-gray-700'
} else if (props.variant.value === 'horizontal') {
return 'flex flex-col items-center bg-white rounded-lg border shadow-md md:flex-row md:max-w-xl hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700'
}
return ''
})
const horizontalImageClasses = computed(() => (props.variant.value === 'horizontal')
? 'object-cover w-full h-96 rounded-t-lg md:h-auto md:w-48 md:rounded-none md:rounded-l-lg'
: '',
)
return {
cardClasses,
horizontalImageClasses,
}
}

View File

@@ -0,0 +1,164 @@
<template>
<div class="relative">
<!-- Carousel wrapper -->
<div class="overflow-hidden relative h-56 rounded-lg sm:h-64 xl:h-80 2xl:h-96">
<!-- Item 1 -->
<!-- duration-700 ease-in-out-->
<div
v-for="(picture, index) in pictures"
:key="index"
:class="index === currentPicture ? 'z-30' : 'z-0'"
class="absolute inset-0 -translate-y-0"
>
<img
:alt="picture.alt"
:src="picture.src"
class="block absolute top-1/2 left-1/2 w-full -translate-x-1/2 -translate-y-1/2"
>
</div>
</div>
<!-- Slider indicators -->
<div
v-if="!noIndicators"
class="flex absolute bottom-5 left-1/2 z-30 space-x-3 -translate-x-1/2"
>
<button
v-for="(picture, index) in pictures"
:key="index"
:aria-label="'Slide ' + index"
:class="index === currentPicture ? 'bg-white' : 'bg-white/50'"
aria-current="false"
class="w-3 h-3 rounded-full bg-white"
type="button"
@click.prevent="slideTo(index)"
/>
</div>
<!-- Slider controls -->
<template v-if="!noControls">
<button
class="flex absolute top-0 left-0 z-30 justify-center items-center px-4 h-full cursor-pointer group focus:outline-none"
data-carousel-prev
type="button"
@click.prevent="previousPicture"
>
<span class="inline-flex justify-center items-center w-8 h-8 rounded-full sm:w-10 sm:h-10 bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg
class="w-5 h-5 text-white sm:w-6 sm:h-6 dark:text-gray-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><path
d="M15 19l-7-7 7-7"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/></svg>
<span class="hidden">Previous</span>
</span>
</button>
<button
class="flex absolute top-0 right-0 z-30 justify-center items-center px-4 h-full cursor-pointer group focus:outline-none"
data-carousel-next
type="button"
@click.prevent="nextPicture"
>
<span class="inline-flex justify-center items-center w-8 h-8 rounded-full sm:w-10 sm:h-10 bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
<svg
class="w-5 h-5 text-white sm:w-6 sm:h-6 dark:text-gray-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><path
d="M9 5l7 7-7 7"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/></svg>
<span class="hidden">Next</span>
</span>
</button>
</template>
</div>
</template>
<script lang="ts" setup>
import { onMounted, type PropType, ref } from 'vue'
import type { PictureItem } from '@/components/FwbCarousel/types'
const props = defineProps({
pictures: {
type: Array as PropType<PictureItem[]>,
default () {
return []
},
},
noIndicators: {
type: Boolean,
default: false,
},
noControls: {
type: Boolean,
default: false,
},
slide: {
type: Boolean,
default: false,
},
slideInterval: {
type: Number,
default: 3000,
},
animation: {
type: Boolean,
default: false,
},
})
const currentPicture = ref(0)
const direction = ref('')
const interval = ref()
const automaticSlide = () => {
interval.value = setInterval(function () {
nextPicture()
}, props.slideInterval)
}
const resetInterval = () => {
clearInterval(interval.value)
automaticSlide()
}
const slideTo = (index: number) => {
currentPicture.value = index
resetInterval()
}
const nextPicture = () => {
if (currentPicture.value !== props.pictures.length - 1) {
currentPicture.value++
} else {
currentPicture.value = 0
}
direction.value = 'right'
resetInterval()
}
const previousPicture = () => {
if (currentPicture.value !== 0) {
currentPicture.value--
} else {
currentPicture.value = props.pictures.length - 1
}
direction.value = 'left'
resetInterval()
}
onMounted(() => {
if (props.slide) {
automaticSlide()
}
})
</script>

View File

@@ -0,0 +1,4 @@
export type PictureItem = {
alt?: string
src: string,
}

View File

@@ -1,7 +1,15 @@
<template>
<label class="flex gap-3 items-center justify-start">
<input v-model="model" type="checkbox" :disabled="disabled" :class="checkboxClasses" />
<span v-if="label" :class="labelClasses">{{ label }}</span>
<input
v-model="model"
:class="checkboxClasses"
:disabled="disabled"
type="checkbox"
>
<span
v-if="label"
:class="labelClasses"
>{{ label }}</span>
<slot />
</label>
</template>
@@ -11,25 +19,28 @@ import { computed } from 'vue'
import { useCheckboxClasses } from './composables/useCheckboxClasses'
interface CheckboxProps {
modelValue?: boolean,
label?: string,
disabled?: boolean,
label?: string,
modelValue?: boolean,
}
const props = withDefaults(defineProps<CheckboxProps>(), {
modelValue: false,
label: '',
disabled: false,
label: '',
modelValue: false,
})
const emit = defineEmits(['update:modelValue'])
const model = computed({
get() {
get () {
return props.modelValue
},
set(val) {
set (val) {
emit('update:modelValue', val)
},
})
const { checkboxClasses, labelClasses } = useCheckboxClasses()
</script>
const {
checkboxClasses,
labelClasses,
} = useCheckboxClasses()
</script>

View File

@@ -7,17 +7,12 @@ const defaultLabelClasses = 'block text-sm font-medium text-gray-900 dark:text-g
// CHECKBOX
const defaultCheckboxClasses = 'w-4 h-4 rounded bg-gray-100 border-gray-300 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-600 dark:border-gray-500'
export function useCheckboxClasses() {
const checkboxClasses = computed(() => {
return simplifyTailwindClasses(defaultCheckboxClasses)
})
export function useCheckboxClasses () {
const checkboxClasses = computed(() => simplifyTailwindClasses(defaultCheckboxClasses))
const labelClasses = computed(() => defaultLabelClasses)
const labelClasses = computed(() => {
return defaultLabelClasses
})
return {
return {
checkboxClasses,
labelClasses,
}
}
}

View File

@@ -0,0 +1,150 @@
<template>
<div
ref="wrapper"
class="inline-flex relative"
>
<div class="inline-flex items-center">
<fwb-slot-listener @click="onToggle">
<slot name="trigger">
<fwb-button>
{{ text }}
<template #suffix>
<svg
class="w-4 h-4 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 9l-7 7-7-7"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</template>
</fwb-button>
</slot>
</fwb-slot-listener>
</div>
<transition :name="transitionName">
<div
v-if="visible"
ref="content"
:class="[contentClasses]"
:style="contentStyles"
>
<fwb-slot-listener @click="onHide">
<slot />
</fwb-slot-listener>
</div>
</transition>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, toRef } from 'vue'
import { onClickOutside } from '@vueuse/core'
import type { DropdownPlacement } from './types'
import FwbButton from '@/components/FwbButton/FwbButton.vue'
import FwbSlotListener from '@/components/utils/FwbSlotListener/FwbSlotListener.vue'
import { useDropdownClasses } from './composables/useDropdownClasses'
const visible = ref(false)
const onHide = () => (visible.value = false)
const onToggle = () => (visible.value = !visible.value)
const props = withDefaults(
defineProps<{
placement?: DropdownPlacement
text?: string
transition?: string
}>(),
{
placement: 'bottom',
text: '',
transition: '',
},
)
const placementTransitionMap: Record<DropdownPlacement, string> = {
bottom: 'to-bottom',
left: 'to-left',
right: 'to-right',
top: 'to-top',
}
const transitionName = computed(() => {
if (props.transition === null) return placementTransitionMap[props.placement]
return props.transition
})
const content = ref<HTMLDivElement>()
const wrapper = ref<HTMLDivElement>()
const { contentClasses, contentStyles } = useDropdownClasses({
placement: toRef(props, 'placement'),
visible,
contentRef: content,
})
onClickOutside(wrapper, () => {
if (!visible.value) return
visible.value = false
})
</script>
<style scoped>
/* transitions */
.to-bottom-enter-active,
.to-bottom-leave-active,
.to-left-enter-active,
.to-left-leave-active,
.to-right-enter-active,
.to-right-leave-active,
.to-top-enter-active,
.to-top-leave-active {
transition: all 250ms;
}
/* to top */
.to-top-enter-active, .to-top-leave-to {
opacity: 0;
transform: translateY(10px);
}
.to-top-leave, .to-top-enter-to {
opacity: 1;
transform: translateY(0);
}
/* to right */
.to-right-enter-active, .to-right-leave-to {
opacity: 0;
transform: translateX(-10px);
}
.to-right-leave, .to-right-enter-to {
opacity: 1;
transform: translateX(0);
}
/* to bottom */
.to-bottom-enter-active, .to-bottom-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.to-bottom-leave, .to-bottom-enter-to {
opacity: 1;
transform: translateY(0);
}
/* to left */
.to-left-enter-active, .to-left-leave-to {
opacity: 0;
transform: translateX(10px);
}
.to-left-leave, .to-left-enter-to {
opacity: 1;
transform: translateX(0);
}
</style>

View File

@@ -0,0 +1,67 @@
import { computed, nextTick, ref, type Ref, watch } from 'vue'
import classNames from 'classnames'
import type { DropdownPlacement } from '../types'
const defaultDropdownClasses = 'absolute z-10 bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700'
const defaultGapInPx = 8
const placementDropdownClasses: Record<DropdownPlacement, string> = {
bottom: '',
left: 'top-0',
right: 'top-0',
top: '',
}
export type UseDropdownClassesProps = {
placement: Ref<DropdownPlacement>
contentRef: Ref<HTMLDivElement | undefined>
visible: Ref<boolean>
}
const placementCalculators: Record<DropdownPlacement, (rect: DOMRect) => string> = {
bottom (rect: DOMRect): string {
return `bottom: -${rect.height + defaultGapInPx}px;`
},
left (rect: DOMRect): string {
return `left: -${rect.width + defaultGapInPx}px;`
},
right (rect: DOMRect): string {
return `right: -${rect.width + defaultGapInPx}px;`
},
top (rect: DOMRect): string {
return `top: -${rect.height + defaultGapInPx}px;`
},
}
export function useDropdownClasses (props: UseDropdownClassesProps): {
contentClasses: Ref<string>
contentStyles: Ref<string>
} {
watch(props.visible, (value: boolean) => {
if (value) nextTick(() => calculatePlacementClasses())
})
const placementStyles = ref('')
const calculatePlacementClasses = () => {
const boundingRect = props.contentRef.value?.getBoundingClientRect()
if (!boundingRect) {
placementStyles.value = ''
return
}
placementStyles.value = placementCalculators[props.placement.value](boundingRect)
}
const contentClasses = computed(() => {
return classNames(
defaultDropdownClasses,
placementDropdownClasses[props.placement.value],
)
})
return {
contentClasses,
contentStyles: placementStyles,
}
}

View File

@@ -1 +1 @@
export type DropdownPlacement = 'top' | 'bottom' | 'left' | 'right'
export type DropdownPlacement = 'top' | 'bottom' | 'left' | 'right'

View File

@@ -3,32 +3,53 @@
<div v-if="!dropzone">
<label>
<span :class="labelClasses">{{ label }}</span>
<input :class="fileInpClasses" :multiple="multiple" @change="handleChange" type="file" />
<input
:class="fileInpClasses"
:multiple="multiple"
type="file"
@change="handleChange"
>
</label>
<slot />
</div>
<div v-else @change="handleChange" @drop="dropFileHandler" @dragover="dragOverHandler" class="flex items-center justify-center">
<div
v-else
class="flex items-center justify-center"
@change="handleChange"
@dragover="dragOverHandler"
@drop="dropFileHandler"
>
<label :class="dropzoneLabelClasses">
<div :class="dropzoneWrapClasses">
<svg class="w-8 h-8 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
<svg
aria-hidden="true"
class="w-8 h-8 text-gray-500 dark:text-gray-400"
fill="none"
viewBox="0 0 20 16"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke="currentColor"
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
stroke="currentColor"
/>
</svg>
<div v-if="!model">
<p :class="dropzoneTextClasses">
<span class="font-semibold"> Click to upload </span>
<span class="font-semibold">Click to upload</span>
or drag and drop
</p>
<slot />
</div>
<p v-else>File: {{ dropZoneText }}</p>
</div>
<input :multiple="multiple" type="file" class="hidden" />
<input
:multiple="multiple"
type="file"
class="hidden"
>
</label>
</div>
</div>
@@ -36,23 +57,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useFileInputClasses } from '@/components/FileInput/composables/useFileInputClasses'
import { isArray } from 'lodash-es'
import { useFileInputClasses } from './composables/useFileInputClasses'
interface FileInputProps {
modelValue?: File | File[] | null
label?: string
size?: string
dropzone?: boolean
label?: string
modelValue?: File | File[] | null
multiple?: boolean
size?: string
}
const props = withDefaults(defineProps<FileInputProps>(), {
modelValue: null,
label: '',
size: 'sm',
dropzone: false,
label: '',
modelValue: null,
multiple: false,
size: 'sm',
})
const dropZoneText = computed(() => {
if (isArray(props.modelValue)) {
return props.modelValue.map((el) => el.name).join(', ')
@@ -62,17 +85,17 @@ const dropZoneText = computed(() => {
.join(',')
} else if (props.modelValue instanceof File) {
return props.modelValue.name || ''
} else {
return ''
}
return ''
})
const emit = defineEmits(['update:modelValue'])
const model = computed({
get() {
get () {
return props.modelValue
},
set(val) {
set (val) {
emit('update:modelValue', val)
},
})
@@ -111,5 +134,11 @@ const dragOverHandler = (event: Event) => {
event.preventDefault()
}
const { fileInpClasses, labelClasses, dropzoneLabelClasses, dropzoneWrapClasses, dropzoneTextClasses } = useFileInputClasses(props.size)
const {
fileInpClasses,
labelClasses,
dropzoneLabelClasses,
dropzoneWrapClasses,
dropzoneTextClasses,
} = useFileInputClasses(props.size)
</script>

View File

@@ -0,0 +1,28 @@
import { computed } from 'vue'
import { simplifyTailwindClasses } from '@/utils/simplifyTailwindClasses'
const fileInpDefaultClasses = 'block w-full text-sm text-gray-900 border-[1px] border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400'
const fileInpLabelClasses = 'block mb-2 text-sm font-medium text-gray-900 dark:text-white'
const fileInpDropzoneClasses = 'flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:hover:bg-bray-800 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600'
const fileDropzoneWrapClasses = 'flex flex-col items-center justify-center pt-5 pb-6'
const fileDropzoneDefaultTextClasses = '!-mb-2 text-sm text-gray-500 dark:text-gray-400'
export function useFileInputClasses (size: string) {
const fileInpClasses = computed(() => simplifyTailwindClasses(
fileInpDefaultClasses,
'text-' + size,
))
const labelClasses = computed(() => fileInpLabelClasses)
const dropzoneLabelClasses = computed(() => fileInpDropzoneClasses)
const dropzoneWrapClasses = computed(() => fileDropzoneWrapClasses)
const dropzoneTextClasses = computed(() => fileDropzoneDefaultTextClasses)
return {
fileInpClasses,
labelClasses,
dropzoneLabelClasses,
dropzoneWrapClasses,
dropzoneTextClasses,
}
}

View File

@@ -1,19 +1,32 @@
<template>
<footer
v-bind="$attrs"
:class="wrapperClasses"
>
<slot />
</footer>
</template>
<script lang="ts" setup>
import { useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({
inheritAttrs: false,
})
type FooterType = 'sitemap' | 'default' | 'logo' | 'socialmedia'
interface IFooterProps {
sticky?: boolean
footerType?: FooterType
}
const props = withDefaults(defineProps<IFooterProps>(), {
sticky: false,
footerType: 'default',
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<IFooterProps>(), {
footerType: 'default',
sticky: false,
})
const attrs = useAttrs()
const wrapperClasses = twMerge(
props.footerType === 'sitemap' && 'bg-gray-800',
@@ -24,9 +37,3 @@ const wrapperClasses = twMerge(
attrs.class as string,
)
</script>
<template>
<footer v-bind="$attrs" :class="wrapperClasses">
<slot></slot>
</footer>
</template>

View File

@@ -1,10 +1,26 @@
<template>
<div
:class="wrapperClasses"
v-bind="$attrs"
>
<a
:class="aClasses"
:href="href"
>
<img
:alt="alt"
:class="imageClasses"
:src="src"
>
<span :class="mameClasses">{{ name }}</span>
</a>
</div>
</template>
<script setup lang="ts">
import { twMerge } from 'tailwind-merge'
import { useAttrs } from 'vue'
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
import { twMerge } from 'tailwind-merge'
interface IFooterProps {
href: string
src: string
@@ -14,6 +30,12 @@ interface IFooterProps {
nameClass?: string
aClass?: string
}
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
const props = withDefaults(defineProps<IFooterProps>(), {
href: '',
src: '',
@@ -29,12 +51,3 @@ const aClasses = twMerge('flex items-center', props.aClass)
const imageClasses = twMerge('h-8 mr-3', props.imageClass)
const mameClasses = twMerge('self-center text-2xl font-semibold whitespace-nowrap dark:text-white', props.nameClass)
</script>
<template>
<div v-bind="$attrs" :class="wrapperClasses">
<a :href="href" :class="aClasses">
<img :src="src" :class="imageClasses" :alt="alt" />
<span :class="mameClasses">{{ name }}</span>
</a>
</div>
</template>

View File

@@ -1,9 +1,22 @@
<template>
<span
v-bind="$attrs"
:class="spanClasses"
>
&copy; {{ year }}
<component
:is="byComponent"
:class="aClasses"
:href="href"
>{{ by }}</component>
{{ copyrightMessage }}
</span>
</template>
<script setup lang="ts">
import { twMerge } from 'tailwind-merge'
import { useAttrs } from 'vue'
defineOptions({
inheritAttrs: false,
})
import { twMerge } from 'tailwind-merge'
interface IFooterCopyrigthProps {
year?: string | number
by?: string
@@ -12,6 +25,10 @@ interface IFooterCopyrigthProps {
copyrightMessage?: string
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<IFooterCopyrigthProps>(), {
year: new Date().getFullYear(),
by: '',
@@ -19,16 +36,9 @@ const props = withDefaults(defineProps<IFooterCopyrigthProps>(), {
aClass: '',
copyrightMessage: 'All Rights Reserved.',
})
const attrs = useAttrs()
const spanClasses = twMerge('block text-sm text-gray-500 sm:text-center dark:text-gray-400', attrs.class as string)
const aClasses = twMerge(props.href ? 'hover:underline' : 'ml-1', props.aClass)
const byComponent = props.href ? 'a' : 'span'
</script>
<template>
<span v-bind="$attrs" :class="spanClasses">
&copy; {{ year }}
<component :is="byComponent" :href="href" :class="aClasses">{{ by }}</component>
{{ copyrightMessage }}
</span>
</template>

View File

@@ -1,28 +1,37 @@
<template>
<component
:is="iconComponent"
:aria-label="ariaLabel"
:class="aClasses"
:href="href"
v-bind="$attrs"
>
<slot />
<span class="sr-only">{{ srText }}</span>
</component>
</template>
<script setup lang="ts">
import { twMerge } from 'tailwind-merge'
import { useAttrs } from 'vue'
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
import { twMerge } from 'tailwind-merge'
interface IFooterIconProps {
href?: string
ariaLabel?: string
srText?: string
}
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
const props = withDefaults(defineProps<IFooterIconProps>(), {
href: '',
ariaLabel: '',
srText: '',
})
const iconComponent = props.href ? 'a' : 'span'
const iconComponent = props.href ? 'a' : 'span'
const aClasses = twMerge('text-gray-500 hover:text-gray-900 dark:hover:text-white', attrs.class as string)
</script>
<template>
<component v-bind="$attrs" :is="iconComponent" :href="href" :aria-label="ariaLabel" :class="aClasses">
<slot />
<span class="sr-only">{{ srText }}</span>
</component>
</template>

View File

@@ -1,15 +1,33 @@
<template>
<li
v-bind="$attrs"
:class="liClasses"
>
<component
:is="linkComponent"
:[linkAttr]="href"
:class="aClasses"
>
<slot />
</component>
</li>
</template>
<script setup lang="ts">
import { resolveComponent, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
interface IFooterLinkProps {
href: string
aClass?: string
component?: string
}
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
const props = withDefaults(defineProps<IFooterLinkProps>(), {
href: '',
aClass: '',
@@ -20,11 +38,3 @@ const linkAttr = props.component === 'router-link' ? 'to' : 'href'
const aClasses = twMerge('hover:underline', props.aClass)
const liClasses = twMerge('mr-4 md:mr-6 last:mr-0', attrs.class as string)
</script>
<template>
<li v-bind="$attrs" :class="liClasses">
<component :is="linkComponent" :[linkAttr]="href" :class="aClasses">
<slot />
</component>
</li>
</template>

View File

@@ -1,15 +1,20 @@
<template>
<ul
v-bind="$attrs"
:class="wrapperClasses"
>
<slot />
</ul>
</template>
<script lang="ts" setup>
import { useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
const wrapperClasses = twMerge('flex flex-wrap items-center mt-3 text-sm font-medium text-gray-500 dark:text-gray-400 sm:mt-0', attrs.class as string)
</script>
<template>
<ul v-bind="$attrs" :class="wrapperClasses">
<slot></slot>
</ul>
</template>

View File

@@ -0,0 +1,86 @@
<template>
<div>
<label
v-if="label"
:class="labelClasses"
>{{ label }}</label>
<div class="flex relative">
<div
v-if="$slots.prefix"
class="w-10 flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none overflow-hidden"
>
<slot name="prefix" />
</div>
<input
v-bind="$attrs"
v-model="model"
:disabled="disabled"
:type="type"
:required="required"
:class="[inputClasses, $slots.prefix ? 'pl-10' : '']"
>
<div
v-if="$slots.suffix"
class="absolute right-2.5 bottom-2.5"
>
<slot name="suffix" />
</div>
</div>
<p
v-if="$slots.validationMessage"
:class="validationWrapperClasses"
>
<slot name="validationMessage" />
</p>
<p
v-if="$slots.helper"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
<slot name="helper" />
</p>
</div>
</template>
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import { useVModel } from '@vueuse/core'
import { twMerge } from 'tailwind-merge'
import { useInputClasses } from './composables/useInputClasses'
import {
type InputSize,
type InputType,
type ValidationStatus,
validationStatusMap,
} from './types'
interface InputProps {
disabled?: boolean
label?: string
modelValue: string
required?: boolean
size?: InputSize
type?: InputType
validationStatus?: ValidationStatus
}
const props = withDefaults(defineProps<InputProps>(), {
disabled: false,
label: '',
modelValue: '',
required: false,
size: 'md',
type: 'text',
validationStatus: undefined,
})
const model = useVModel(props, 'modelValue')
const { inputClasses, labelClasses } = useInputClasses(toRefs(props))
const validationWrapperClasses = computed(() => twMerge(
'mt-2 text-sm',
props.validationStatus === validationStatusMap.Success ? 'text-green-600 dark:text-green-500' : '',
props.validationStatus === validationStatusMap.Error ? 'text-red-600 dark:text-red-500' : '',
))
</script>

View File

@@ -1,7 +1,11 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { ValidationStatus, type InputSize } from '@/components/Input/types'
import { computed, type Ref } from 'vue'
import { twMerge } from 'tailwind-merge'
import {
type InputSize,
type ValidationStatus,
validationStatusMap,
} from '../types'
// LABEL
const baseLabelClasses = 'block mb-2 text-sm font-medium'
@@ -15,8 +19,7 @@ const inputSizeClasses: Record<InputSize, string> = {
sm: 'p-2 text-sm',
}
const successInputClasses =
'bg-green-50 border-green-500 dark:border-green-500 text-green-900 dark:text-green-400 placeholder-green-700 dark:placeholder-green-500 focus:ring-green-500 focus:border-green-500'
const successInputClasses = 'bg-green-50 border-green-500 dark:border-green-500 text-green-900 dark:text-green-400 placeholder-green-700 dark:placeholder-green-500 focus:ring-green-500 focus:border-green-500'
const errorInputClasses = 'bg-red-50 border-red-500 text-red-900 placeholder-red-700 focus:ring-red-500 focus:border-red-500 dark:text-red-500 dark:placeholder-red-500 dark:border-red-500'
export type UseInputClassesProps = {
@@ -25,19 +28,35 @@ export type UseInputClassesProps = {
validationStatus: Ref<ValidationStatus | undefined>
}
export function useInputClasses(props: UseInputClassesProps): {
export function useInputClasses (props: UseInputClassesProps): {
inputClasses: Ref<string>
labelClasses: Ref<string>
} {
const inputClasses = computed(() => {
const vs = props.validationStatus.value
const classByStatus = vs === ValidationStatus.Success ? successInputClasses : vs == ValidationStatus.Error ? errorInputClasses : ''
return twMerge(defaultInputClasses, classByStatus, inputSizeClasses[props.size.value], props.disabled.value ? disabledInputClasses : '')
const classByStatus = vs === validationStatusMap.Success
? successInputClasses
: vs === validationStatusMap.Error
? errorInputClasses
: ''
return twMerge(
defaultInputClasses,
classByStatus,
inputSizeClasses[props.size.value],
props.disabled.value ? disabledInputClasses : '',
)
})
const labelClasses = computed(() => {
const vs = props.validationStatus.value
const classByStatus = vs === ValidationStatus.Success ? 'text-green-700 dark:text-green-500' : vs == ValidationStatus.Error ? 'text-red-700 dark:text-red-500' : 'text-gray-900 dark:text-gray-300'
const classByStatus = vs === validationStatusMap.Success
? 'text-green-700 dark:text-green-500'
: vs === validationStatusMap.Error
? 'text-red-700 dark:text-red-500'
: 'text-gray-900 dark:text-gray-300'
return twMerge(baseLabelClasses, classByStatus)
})

View File

@@ -0,0 +1,10 @@
export type InputSize = 'sm' | 'md' | 'lg'
export type InputType = 'button' | 'checkbox' | 'color' | 'date' | 'datetime-local' | 'email' | 'file' | 'hidden' | 'image' | 'month' | 'number' | 'password' | 'radio' | 'range' | 'reset' | 'search' | 'submit' | 'tel' | 'text' | 'time' | 'url' | 'week'
export const validationStatusMap = {
Success: 'success',
Error: 'error',
} as const
export type ValidationStatus = typeof validationStatusMap[keyof typeof validationStatusMap]

View File

@@ -3,6 +3,7 @@
<slot />
</ul>
</template>
<script lang="ts" setup>
import { useListGroupClasses } from './composables/useListGroupClasses'

View File

@@ -1,24 +1,29 @@
<template>
<li :class="itemClasses">
<div class="mr-2" v-if="$slots.prefix">
<div
v-if="$slots.prefix"
class="mr-2"
>
<slot name="prefix" />
</div>
<slot />
<div class="ml-2" v-if="$slots.suffix">
<div
v-if="$slots.suffix"
class="ml-2"
>
<slot name="suffix" />
</div>
</li>
</template>
<script lang="ts" setup>
import {
useListGroupItemClasses,
} from './composables/useListGroupItemClasses'
import { toRefs } from 'vue'
import { useListGroupItemClasses } from './composables/useListGroupItemClasses'
const props = defineProps({
hover: {
type: Boolean,
default: true,
default: false,
},
disabled: {
type: Boolean,

View File

@@ -0,0 +1,18 @@
import { computed, type Ref } from 'vue'
import classNames from 'classnames'
const defaultContainerClasses = 'overflow-hidden w-48 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white'
export function useListGroupClasses (): {
containerClasses: Ref<string>,
} {
const containerClasses = computed<string>(() => {
return classNames(
defaultContainerClasses,
)
})
return {
containerClasses,
}
}

View File

@@ -0,0 +1,27 @@
import { computed, type Ref } from 'vue'
import { simplifyTailwindClasses } from '@/utils/simplifyTailwindClasses'
const defaultItemClasses = 'inline-flex items-center w-full px-4 py-2 border-b border-gray-200 dark:border-gray-600'
const hoverItemClasses = 'block w-full px-4 py-2 cursor-pointer hover:bg-gray-100 hover:text-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:hover:bg-gray-600 dark:hover:text-white dark:focus:ring-gray-500 dark:focus:text-white'
const disabledItemClasses = 'bg-gray-100 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
export type UseListGroupItemClassesProps = {
hover: Ref<boolean>,
disabled: Ref<boolean>,
}
export function useListGroupItemClasses (props: UseListGroupItemClassesProps): {
itemClasses: Ref<string>,
} {
const itemClasses = computed<string>(() => {
return simplifyTailwindClasses(
defaultItemClasses,
props.disabled.value ? disabledItemClasses : '',
!props.disabled.value && props.hover.value ? hoverItemClasses : '',
)
})
return {
itemClasses,
}
}

View File

@@ -0,0 +1,114 @@
<template>
<div>
<div class="bg-gray-900 bg-opacity-50 dark:bg-opacity-80 fixed inset-0 z-40" />
<div
ref="modalRef"
class="overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 w-full md:inset-0 h-modal md:h-full justify-center items-center flex"
tabindex="0"
@click.self="clickOutside"
@keyup.esc="closeWithEsc"
>
<div
:class="`${modalSizeClasses[size]}`"
class="relative p-4 w-full h-full"
>
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Modal header -->
<div
:class="$slots.header ? 'border-b border-gray-200 dark:border-gray-600' : ''"
class="p-4 rounded-t flex justify-between items-center"
>
<slot name="header" />
<button
v-if="!persistent"
aria-label="close"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
type="button"
@click="closeModal"
>
<slot name="close-icon">
<svg
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
><path
clip-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
fill-rule="evenodd"
/></svg>
</slot>
</button>
</div>
<!-- Modal body -->
<div
:class="$slots.header ? '' : 'pt-0'"
class="p-6"
>
<slot name="body" />
</div>
<!-- Modal footer -->
<div
v-if="$slots.footer"
class="p-6 rounded-b border-gray-200 border-t dark:border-gray-600"
>
<slot name="footer" />
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, type Ref } from 'vue'
import type { ModalSize } from './types'
interface ModalProps {
notEscapable?: boolean,
persistent?: boolean
size?: ModalSize,
}
const props = withDefaults(defineProps<ModalProps>(), {
notEscapable: false,
persistent: false,
size: '2xl',
})
const emit = defineEmits(['close', 'click:outside'])
const modalSizeClasses = {
xs: 'max-w-xs',
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
'4xl': 'max-w-4xl',
'5xl': 'max-w-5xl',
'6xl': 'max-w-6xl',
'7xl': 'max-w-7xl',
}
function closeModal () {
emit('close')
}
function clickOutside () {
if (!props.persistent) {
emit('click:outside')
closeModal()
}
}
function closeWithEsc () {
if (!props.notEscapable && !props.persistent) closeModal()
}
const modalRef: Ref<HTMLElement | null> = ref(null)
onMounted(() => {
if (modalRef.value) {
modalRef.value.focus()
}
})
</script>

View File

@@ -0,0 +1,83 @@
<template>
<nav :class="navbarClasses">
<div class="container flex flex-wrap justify-between items-center mx-auto">
<slot name="logo" />
<button
aria-controls="navbar-default"
aria-expanded="false"
class="inline-flex items-center p-2 ml-3 text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
type="button"
@click="toggleMobileMenu()"
>
<span class="sr-only">Open main menu</span>
<slot name="menu-icon">
<svg
aria-hidden="true"
class="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
><path
clip-rule="evenodd"
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
fill-rule="evenodd"
/></svg>
</slot>
</button>
<slot
:is-show-menu="isShowMenu"
name="default"
/>
<div
v-if="slots['right-side']"
class="hidden md:order-2 md:flex"
>
<slot name="right-side" />
</div>
</div>
</nav>
</template>
<script lang="ts" setup>
import { computed, ref, useSlots } from 'vue'
import { breakpointsTailwind, useBreakpoints, useToggle } from '@vueuse/core'
import classNames from 'classnames'
const props = defineProps({
sticky: {
type: Boolean,
default: false,
},
rounded: {
type: Boolean,
default: false,
},
solid: {
type: Boolean,
default: false,
},
})
const slots = useSlots()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
const isShowMenuOnMobile = ref(false)
const toggleMobileMenu = useToggle(isShowMenuOnMobile)
const navbarBaseClasses = ' border-gray-200'
const navbarFloatClasses = 'fixed w-full z-20 top-0 left-0 border-b border-gray-200 dark:border-gray-600'
const navbarRoundedClasses = 'rounded'
const navbarSolidClasses = 'p-3 bg-gray-50 dark:bg-gray-800 dark:border-gray-700'
const navbarWhiteClasses = 'bg-white px-2 sm:px-4 py-2.5 dark:bg-gray-900'
const navbarClasses = computed(() => classNames(
navbarBaseClasses,
props.sticky ? navbarFloatClasses : '',
props.rounded ? navbarRoundedClasses : '',
props.solid ? navbarSolidClasses : navbarWhiteClasses,
))
const isShowMenu = computed(() => (!isMobile)
? true
: isShowMenuOnMobile.value,
)
</script>

View File

@@ -7,9 +7,10 @@
</template>
<script setup lang="ts">
import classNames from 'classnames'
import { computed } from 'vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import classNames from 'classnames'
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
const props = defineProps({
@@ -22,10 +23,12 @@ const props = defineProps({
const menuClassesDefault = 'w-full md:block md:w-auto'
const listClassesDefault = 'flex flex-col p-4 mt-4 rounded-lg border border-gray-100 md:flex-row md:space-x-8 md:mt-0 md:text-sm md:font-medium md:border-0 dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700'
const mobileListClasses = 'bg-gray-50'
const menuClasses = computed(() => {
return classNames(menuClassesDefault, props.isShowMenu ? '': 'hidden')
})
const listClasses = computed(() => {
return classNames(listClassesDefault, isMobile.value ? mobileListClasses : '')
})
const menuClasses = computed(() => classNames(
menuClassesDefault,
props.isShowMenu ? '' : 'hidden',
))
const listClasses = computed(() => classNames(
listClassesDefault,
isMobile.value ? mobileListClasses : '',
))
</script>

View File

@@ -0,0 +1,50 @@
<template>
<li>
<component
:is="componentName"
:[linkAttr]="link"
:class="linkClasses"
@click="handleClick"
>
<slot />
</component>
</li>
</template>
<script setup lang="ts">
import { twMerge } from 'tailwind-merge'
import { computed, resolveComponent } from 'vue'
interface IFwbNavbarLinkProps {
link?: string,
isActive?: boolean,
component?: string,
linkAttr?: string,
disabled?: boolean
}
const props = withDefaults(defineProps<IFwbNavbarLinkProps>(), {
link: '/',
isActive: false,
component: 'a',
linkAttr: 'href',
disabled: false,
})
const emit = defineEmits<{click: [event: Event] }>()
const currentPageClasses = 'bg-blue-700 md:bg-transparent text-white md:text-blue-700 dark:text-white'
const defaultStateClasses = 'text-gray-700 hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent'
const defaultClasses = 'block py-2 pr-4 pl-3 rounded md:p-0'
const componentName = computed(() => {
return props.component !== 'a' ? resolveComponent(props.component) : 'a'
})
const linkClasses = twMerge(
defaultClasses,
props.isActive ? currentPageClasses : defaultStateClasses,
)
const handleClick = (event: Event) => {
if (props.disabled) {
return
}
emit('click', event)
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<component
:is="componentName"
class="flex items-center"
:[linkAttr]="link"
>
<img
:src="imageUrl"
:alt="alt"
class="mr-3 h-6 sm:h-10"
>
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">
<slot />
</span>
</component>
</template>
<script setup lang="ts">
import { computed, resolveComponent } from 'vue'
interface IFwbNavbarLogoProps {
link?: string
imageUrl?: string
alt?: string
component?: string
linkAttr?: string
}
const props = withDefaults(defineProps<IFwbNavbarLogoProps>(), {
link: '/',
imageUrl: '/assets/logo.svg',
alt: 'Logo',
component: 'a',
linkAttr: 'href',
})
const componentName = computed(() => {
return props.component !== 'a' ? resolveComponent(props.component) : 'a'
})
</script>

View File

@@ -1,6 +1,10 @@
<template>
<nav aria-label="Navigation">
<div class="text-gray-700 dark:text-gray-400 mb-2" :class="large ? 'text-base' : 'text-sm'" v-if="layout === 'table'">
<div
v-if="layout === 'table'"
class="text-gray-700 dark:text-gray-400 mb-2"
:class="large ? 'text-base' : 'text-sm'"
>
Showing
<span class="font-semibold text-gray-900 dark:text-white">{{ startItemsCount }}</span>
to
@@ -8,15 +12,35 @@
of
<span class="font-semibold text-gray-900 dark:text-white">{{ computedTotalItems }}</span>
</div>
<div class="inline-flex" :class="large && 'text-base h-10'">
<div
class="inline-flex"
:class="large && 'text-base h-10'"
>
<slot name="start" />
<slot name="first-button" v-if="enableFirstAndLastButtons">
<button :disabled="isFirstPage" @click="goToFirstPage" :class="getNavigationButtonClasses(1)">First</button>
<slot
v-if="enableFirstAndLastButtons"
name="first-button"
>
<button
:disabled="isFirstPage"
:class="getNavigationButtonClasses(1)"
@click="goToFirstPage"
>
First
</button>
</slot>
<slot name="prev-button" :disabled="isDecreaseDisabled" :decrease-page="decreasePage">
<button :disabled="isDecreaseDisabled" @click="decreasePage" :class="getNavigationButtonClasses(modelValue - 1)">
<slot
name="prev-button"
:disabled="isDecreaseDisabled"
:decrease-page="decreasePage"
>
<button
:disabled="isDecreaseDisabled"
:class="getNavigationButtonClasses(modelValue - 1)"
@click="decreasePage"
>
<slot name="prev-icon">
<svg
v-if="showIcons || $slots['prev-icon']"
@@ -30,19 +54,44 @@
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"></path>
<path
fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</slot>
<template v-if="showLabels">{{ previousLabel }}</template>
<template v-if="showLabels">
{{ previousLabel }}
</template>
</button>
</slot>
<slot v-for="index in pagesToDisplay" :key="index" name="page-button" :page="index" :set-page="setPage" :disabled="isSetPageDisabled(index)">
<button :disabled="isSetPageDisabled(index)" @click="setPage(index)" :class="getPageButtonClasses(index === modelValue)">
<slot
v-for="index in pagesToDisplay"
:key="index"
name="page-button"
:page="index"
:set-page="setPage"
:disabled="isSetPageDisabled(index)"
>
<button
:disabled="isSetPageDisabled(index)"
:class="getPageButtonClasses(index === modelValue)"
@click="setPage(index)"
>
{{ index }}
</button>
</slot>
<slot name="next-button" :disabled="isIncreaseDisabled" :increase-page="increasePage">
<button :disabled="isIncreaseDisabled" @click="increasePage" :class="getNavigationButtonClasses(modelValue + 1)">
<slot
name="next-button"
:disabled="isIncreaseDisabled"
:increase-page="increasePage"
>
<button
:disabled="isIncreaseDisabled"
:class="getNavigationButtonClasses(modelValue + 1)"
@click="increasePage"
>
<template v-if="showLabels">
{{ nextLabel }}
</template>
@@ -59,23 +108,37 @@
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</slot>
</button>
</slot>
<slot name="last-button" v-if="enableFirstAndLastButtons">
<button :disabled="isLastPage" @click="goToLastPage" :class="getNavigationButtonClasses(computedTotalPages)">Last</button>
<slot
v-if="enableFirstAndLastButtons"
name="last-button"
>
<button
:disabled="isLastPage"
:class="getNavigationButtonClasses(computedTotalPages)"
@click="goToLastPage"
>
Last
</button>
</slot>
<slot name="end" />
</div>
</nav>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import type { PaginationLayout } from '@/components/Pagination/types'
import type { PaginationLayout } from './types'
import { twMerge } from 'tailwind-merge'
const emit = defineEmits<{
@@ -124,23 +187,23 @@ defineSlots<{
end: any
/* eslint-enable @typescript-eslint/no-explicit-any */
}>()
function setPage(index: number) {
function setPage (index: number) {
emit('update:model-value', index)
emit('page-changed', index)
}
function decreasePage() {
function decreasePage () {
emit('update:model-value', props.modelValue - 1)
emit('page-changed', props.modelValue - 1)
}
function increasePage() {
function increasePage () {
emit('update:model-value', props.modelValue + 1)
emit('page-changed', props.modelValue + 1)
}
function goToFirstPage() {
function goToFirstPage () {
emit('update:model-value', 1)
emit('page-changed', 1)
}
function goToLastPage() {
function goToLastPage () {
const lastPage = computedTotalPages.value
emit('update:model-value', lastPage)
emit('page-changed', lastPage)
@@ -160,14 +223,14 @@ const pagesToDisplay = computed(() => {
if (props.layout === 'table') return []
if (computedTotalPages.value <= props.sliceLength * 2 + 1) {
const pages = []
const pages: number[] = []
for (let page = 1; page <= computedTotalPages.value; page++) {
pages.push(page)
}
return pages
}
if (props.modelValue <= props.sliceLength) {
const pages = []
const pages: number[] = []
const slicedLength = Math.abs(props.modelValue - props.sliceLength) + props.modelValue + props.sliceLength + 1
for (let page = 1; page <= slicedLength; page++) {
pages.push(page)
@@ -175,15 +238,15 @@ const pagesToDisplay = computed(() => {
return pages
}
if (props.modelValue >= computedTotalPages.value - props.sliceLength) {
const pages = []
const pages: number[] = []
for (let page = Math.abs(computedTotalPages.value - props.sliceLength * 2); page <= computedTotalPages.value; page++) {
pages.push(page)
}
return pages
}
const pages = []
let startedPage = props.modelValue - props.sliceLength > 0 ? props.modelValue - props.sliceLength : 1
const pages: number[] = []
const startedPage = props.modelValue - props.sliceLength > 0 ? props.modelValue - props.sliceLength : 1
for (let page = startedPage; page < props.modelValue + props.sliceLength + 1; page++) {
if (page >= computedTotalPages.value) break
pages.push(page)
@@ -206,14 +269,14 @@ const computedTotalItems = computed(() => {
const isFirstPage = computed(() => props.modelValue === 1)
const isLastPage = computed(() => props.modelValue === computedTotalPages.value)
function getPageButtonClasses(active: boolean) {
function getPageButtonClasses (active: boolean) {
const baseClasses =
'flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white'
const activeClasses = 'text-blue-600 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:bg-gray-700 dark:text-white'
const largeClasses = 'px-4 h-10'
return twMerge(baseClasses, active && activeClasses, props.large && largeClasses)
}
function getNavigationButtonClasses(toPage: number) {
function getNavigationButtonClasses (toPage: number) {
const baseClasses =
'flex items-center justify-center first:rounded-l-lg last:rounded-r-lg px-3 h-8 ml-0 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white'
const disabledClasses = 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-white cursor-not-allowed'

View File

@@ -0,0 +1,61 @@
<template>
<div>
<template v-if="label || (labelProgress && labelPosition === 'outside')">
<div class="flex justify-between mb-1">
<span
:class="outsideLabelClasses"
class="text-base font-medium"
>{{ label }}</span>
<span
v-if="labelProgress && labelPosition === 'outside'"
:class="outsideLabelClasses"
class="text-sm font-medium"
>{{ progress }}%</span>
</div>
</template>
<div
:class="outerClasses"
class="w-full bg-gray-200 rounded-full dark:bg-gray-700"
>
<div
:class="innerClasses"
:style="{ width: progress + '%' }"
class="rounded-full font-medium text-blue-100 text-center p-0.5"
>
<template v-if="labelProgress && labelPosition === 'inside'">
{{ progress }}%
</template>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { toRefs } from 'vue'
import { useProgressClasses } from './composables/useProgressClasses'
import type { ProgressLabelPosition, ProgressSize, ProgressVariant } from './types'
interface IProgressProps {
color?: ProgressVariant
label?: string
labelPosition?: ProgressLabelPosition
labelProgress?: boolean
progress?: number
size?: ProgressSize
}
const props = withDefaults(defineProps<IProgressProps>(), {
color: 'default',
label: '',
labelPosition: 'none',
labelProgress: false,
progress: 0,
size: 'md',
})
const {
innerClasses,
outerClasses,
outsideLabelClasses,
} = useProgressClasses(toRefs(props))
</script>

View File

@@ -1,7 +1,6 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { computed, type Ref } from 'vue'
import classNames from 'classnames'
import type { ProgressVariant, ProgressSize, ProgressLabelPosition } from '../types'
import type { ProgressLabelPosition, ProgressSize, ProgressVariant } from '../types'
const barColorClasses: Record<ProgressVariant, string> = {
default: 'bg-blue-600 dark:bg-blue-600',
@@ -15,14 +14,14 @@ const barColorClasses: Record<ProgressVariant, string> = {
}
const outsideTextColorClasses: Record<ProgressVariant, string> = {
default: '',
blue: 'text-blue-700 dark:text-blue-500',
dark: 'dark:text-white',
green: 'text-green-700 dark:text-green-500',
red: 'text-red-700 dark:text-red-500',
yellow: 'text-yellow-700 dark:text-yellow-500',
indigo: 'text-indigo-700 dark:text-indigo-500',
purple: 'text-purple-700 dark:text-purple-500',
default: '',
blue: 'text-blue-700 dark:text-blue-500',
dark: 'dark:text-white',
green: 'text-green-700 dark:text-green-500',
red: 'text-red-700 dark:text-red-500',
yellow: 'text-yellow-700 dark:text-yellow-500',
indigo: 'text-indigo-700 dark:text-indigo-500',
purple: 'text-purple-700 dark:text-purple-500',
}
const progressSizeClasses: Record<ProgressSize, string> = {
@@ -38,21 +37,21 @@ export type UseProgressClassesProps = {
labelPosition: Ref<ProgressLabelPosition>
}
export function useProgressClasses(props: UseProgressClassesProps): { innerClasses: Ref<string>, outerClasses: Ref<string>, outsideLabelClasses: Ref<string>} {
export function useProgressClasses (props: UseProgressClassesProps): { innerClasses: Ref<string>, outerClasses: Ref<string>, outsideLabelClasses: Ref<string>} {
const bindClasses = computed(() => {
return classNames(
barColorClasses[props.color.value],
progressSizeClasses[props.size.value],
barColorClasses[props.color.value],
progressSizeClasses[props.size.value],
)
})
const outerClasses = computed(() => {
return classNames(
progressSizeClasses[props.size.value],
progressSizeClasses[props.size.value],
)
})
const outsideLabelClasses = computed(() => {
return classNames(
outsideTextColorClasses[props.color.value],
outsideTextColorClasses[props.color.value],
)
})

View File

@@ -1,3 +1,3 @@
export type ProgressVariant = 'default' | 'dark' | 'green' | 'red' | 'yellow' | 'purple' | 'blue' | 'indigo'
export type ProgressSize = 'sm' | 'md' | 'lg' | 'xl'
export type ProgressLabelPosition = 'inside' | 'outside' | 'none'
export type ProgressSize = 'sm' | 'md' | 'lg' | 'xl'
export type ProgressVariant = 'default' | 'dark' | 'green' | 'red' | 'yellow' | 'purple' | 'blue' | 'indigo'

View File

@@ -0,0 +1,54 @@
<template>
<label class="flex w-[100%] items-center">
<input
v-model="model"
type="radio"
:disabled="disabled"
:name="name"
:value="value"
:class="radioClasses"
>
<span :class="labelClasses">{{ label }}</span>
<slot />
</label>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { twMerge } from 'tailwind-merge'
interface RadioProps {
modelValue?: string;
name?: string;
value?: string;
label?: string;
disabled?: boolean;
}
const radioDefaultClasses = 'w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
const radioLabelClasses = 'm-2 mr-0 text-sm font-medium text-gray-900 dark:text-gray-300'
const props = withDefaults(defineProps<RadioProps>(), {
modelValue: '',
name: '',
value: '',
label: '',
disabled: false,
})
const emit = defineEmits(['update:modelValue'])
const model = computed({
get () {
return props.modelValue
},
set (val) {
emit('update:modelValue', val)
},
})
const radioClasses = computed(() => {
return radioDefaultClasses
})
const labelClasses = computed(() => {
return twMerge(radioLabelClasses, props.disabled && 'text-gray-400 dark:text-gray-500')
})
</script>

View File

@@ -0,0 +1,66 @@
<template>
<label class="flex flex-col">
<span
:class="labelClasses"
>{{ label }}</span>
<input
v-model="model"
:step="steps"
:min="min"
:max="max"
:disabled="disabled"
type="range"
:class="rangeClasses"
>
</label>
</template>
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import { useRangeClasses } from './composables/useRangeClasses'
import type { InputSize } from '@/components/FwbInput/types'
interface RangeProps {
disabled?: boolean
label?: string
max?: number
min?: number
modelValue?: number
size?: InputSize
steps?: number
}
const props = withDefaults(defineProps<RangeProps>(), {
disabled: false,
label: 'Range slider',
max: 100,
min: 0,
modelValue: 50,
size: 'md',
steps: 1,
})
const emit = defineEmits(['update:modelValue'])
const model = computed({
get () {
return props.modelValue
},
set (val) {
emit('update:modelValue', val)
},
})
const { rangeClasses, labelClasses } = useRangeClasses(toRefs(props))
</script>
<style scoped>
input[type="range"].range-lg::-moz-range-thumb {
height: 1.5rem;
width: 1.5rem;
}
input[type="range"].range-sm::-moz-range-thumb {
height: 1rem;
width: 1rem;
}
</style>

View File

@@ -1,9 +1,7 @@
import { computed, type Ref } from 'vue'
import { simplifyTailwindClasses } from '@/utils/simplifyTailwindClasses'
import { computed } from 'vue'
import type { Ref } from 'vue'
import type { InputSize } from '@/components/Input/types'
import type { InputSize } from '@/components/FwbInput/types'
// Range
const rangeDefaultClasses = 'w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700'
const rangeLabelClasses = 'block mb-2 text-sm font-medium text-gray-900 dark:text-white'
@@ -18,17 +16,16 @@ export type UseRangeClassesProps = {
disabled: Ref<boolean>
}
export function useRangeClasses(props: UseRangeClassesProps) {
const rangeClasses = computed(() => {
return simplifyTailwindClasses(rangeDefaultClasses, rangeSizeClasses[props.size.value])
})
export function useRangeClasses (props: UseRangeClassesProps) {
const rangeClasses = computed(() => simplifyTailwindClasses(
rangeDefaultClasses,
rangeSizeClasses[props.size.value],
))
const labelClasses = computed(() => {
return rangeLabelClasses
})
const labelClasses = computed(() => rangeLabelClasses)
return {
rangeClasses,
labelClasses,
}
}
}

View File

@@ -2,33 +2,33 @@
<div class="flex items-center">
<!-- valid stars -->
<svg
v-for="valid_star_index in valid_star_number"
:key="valid_star_index"
class="text-yellow-400"
v-for="validStarIndex in validStarNumber"
:key="validStarIndex"
:class="sizeClasses"
class="text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<!-- invalid stars -->
<svg
v-for="invalid_star_index in invalid_star_number"
:key="invalid_star_index"
class="text-gray-300 dark:text-gray-500"
v-for="invalidStarIndex in invalidStarNumber"
:key="invalidStarIndex"
:class="sizeClasses"
class="text-gray-300 dark:text-gray-500"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<!-- text -->
<slot name="besideText"></slot>
<slot name="besideText" />
<!-- review link -->
<template v-if="(reviewText && reviewLink)">
<span class="w-1 h-1 mx-1.5 bg-gray-500 rounded-full dark:bg-gray-400"></span>
<span class="w-1 h-1 mx-1.5 bg-gray-500 rounded-full dark:bg-gray-400" />
<a
:href="reviewLink"
class="text-sm font-medium text-gray-900 underline hover:no-underline dark:text-white"
@@ -41,41 +41,27 @@
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import type { PropType } from 'vue'
import { useRatingClasses } from './composables/useRatingClasses'
import type { RatingSize } from './types'
const props = defineProps({
size: {
type: String as PropType<RatingSize>,
default: 'sm',
},
rating: {
type: Number,
default: 3,
},
scale: {
type: Number,
default: 5,
},
reviewText: {
type: String,
default: '',
},
reviewLink: {
type: String,
default: '',
},
interface IRatingProps {
rating?: number
reviewLink?: string
reviewText?: string
scale?: number
size?: RatingSize
}
const props = withDefaults(defineProps<IRatingProps>(), {
rating: 3,
reviewLink: '',
reviewText: '',
scale: 5,
size: 'md',
})
const valid_star_number = computed(() => {
return Math.floor(props.rating)
})
const invalid_star_number = computed(() => {
return props.scale - valid_star_number.value
})
const validStarNumber = computed(() => Math.floor(props.rating))
const invalidStarNumber = computed(() => props.scale - validStarNumber.value)
const { sizeClasses } = useRatingClasses(toRefs(props))
</script>

View File

@@ -1,5 +1,4 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { computed, type Ref } from 'vue'
import classNames from 'classnames'
import type { RatingSize } from '../types'
@@ -13,16 +12,12 @@ export type UseRatingClassesProps = {
size: Ref<RatingSize>
}
export function useRatingClasses(props: UseRatingClassesProps):{
export function useRatingClasses (props: UseRatingClassesProps):{
sizeClasses: Ref<string>
}{
const sizeClasses = computed(() => {
return classNames(
ratingSizeClasses[props.size.value] ?? '',
)
})
} {
const sizeClasses = computed(() => classNames(
ratingSizeClasses[props.size.value] ?? '',
))
return {
sizeClasses,
}
return { sizeClasses }
}

View File

@@ -0,0 +1 @@
export type RatingSize = 'sm' | 'md' | 'lg'

View File

@@ -0,0 +1,86 @@
<template>
<label>
<span
v-if="label"
:class="labelClasses"
>
{{ label }}
</span>
<select
v-model="model"
:disabled="disabled"
:class="selectClasses"
>
<option
disabled
selected
value=""
>
{{ placeholder }}
</option>
<option
v-for="(option, index) in options"
:key="index"
:value="option.value"
>
{{ option.name }}
</option>
</select>
</label>
</template>
<script lang="ts" setup>
import type { InputSize } from './../FwbInput/types'
import type { OptionsType } from './types'
import { computed } from 'vue'
import { useVModel } from '@vueuse/core'
import { twMerge } from 'tailwind-merge'
interface InputProps {
modelValue?: string;
label?: string;
options?: OptionsType[];
placeholder?: string;
disabled?: boolean;
underline?: boolean;
size?: InputSize;
}
const props = withDefaults(defineProps<InputProps>(), {
modelValue: '',
label: '',
options: () => [],
placeholder: 'Please select one',
disabled: false,
underline: false,
size: 'md',
})
const emit = defineEmits(['update:modelValue'])
const model = useVModel(props, 'modelValue', emit)
// LABEL
const defaultLabelClasses = 'block mb-2 text-sm font-medium text-gray-900 dark:text-white'
// SELECT
const defaultSelectClasses = 'w-full text-gray-900 bg-gray-50 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500'
const disabledSelectClasses = 'cursor-not-allowed bg-gray-100'
const underlineSelectClasses = 'bg-transparent dark:bg-transparent border-b-2 border-gray-200 appearance-none dark:border-gray-700 focus:outline-none focus:ring-0 focus:border-gray-200 peer'
const selectSizeClasses: Record<InputSize, string> = {
lg: 'p-4',
md: 'p-2.5 text-sm',
sm: 'p-2 text-sm',
}
const selectClasses = computed(() => {
return twMerge(
defaultSelectClasses,
selectSizeClasses[props.size],
props.disabled && disabledSelectClasses,
props.underline ? underlineSelectClasses : 'border border-gray-300 rounded-lg',
)
})
const labelClasses = computed(() => {
return defaultLabelClasses
})
</script>

View File

@@ -1,4 +1,4 @@
export type OptionsType = {
value: string,
name: string,
value: string,
}

View File

@@ -1,5 +1,9 @@
<template>
<aside v-bind="$attrs" :class="wrapperClasses" aria-label="Sidebar">
<aside
v-bind="$attrs"
:class="wrapperClasses"
aria-label="Sidebar"
>
<div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:bg-gray-800">
<div class="space-y-2 font-medium">
<slot />

View File

@@ -1,3 +1,38 @@
<template>
<div
class="p-4 mt-6 rounded-lg bg-blue-50 dark:bg-blue-900"
role="alert"
>
<div class="flex items-center mb-3">
<span class="bg-orange-100 text-orange-800 text-sm font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-orange-200 dark:text-orange-900">{{ label }}</span>
<button
type="button"
class="ml-auto -mx-1.5 -my-1.5 bg-blue-50 inline-flex justify-center items-center w-6 h-6 text-blue-900 rounded-lg focus:ring-2 focus:ring-blue-400 p-1 hover:bg-blue-200 h-6 w-6 dark:bg-blue-900 dark:text-blue-400 dark:hover:bg-blue-800"
aria-label="Close"
@click="close"
>
<span class="sr-only">Close</span>
<svg
class="w-2.5 h-2.5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
</button>
</div>
<slot name="default" />
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
@@ -8,31 +43,9 @@ withDefaults(
},
)
const emit = defineEmits<{
(e: 'close'): void
}>()
const emit = defineEmits<{(e: 'close'): void }>()
function close() {
function close () {
emit('close')
}
</script>
<template>
<div class="p-4 mt-6 rounded-lg bg-blue-50 dark:bg-blue-900" role="alert">
<div class="flex items-center mb-3">
<span class="bg-orange-100 text-orange-800 text-sm font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-orange-200 dark:text-orange-900">{{ label }}</span>
<button
@click="close"
type="button"
class="ml-auto -mx-1.5 -my-1.5 bg-blue-50 inline-flex justify-center items-center w-6 h-6 text-blue-900 rounded-lg focus:ring-2 focus:ring-blue-400 p-1 hover:bg-blue-200 h-6 w-6 dark:bg-blue-900 dark:text-blue-400 dark:hover:bg-blue-800"
aria-label="Close"
>
<span class="sr-only">Close</span>
<svg class="w-2.5 h-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
</button>
</div>
<slot name="default" />
</div>
</template>

View File

@@ -1,11 +1,3 @@
<script setup lang="ts">
import { ref } from 'vue'
const isOpen = ref(false)
function toggleDropdown() {
isOpen.value = !isOpen.value
}
</script>
<template>
<div class="overflow-hidden">
<button
@@ -30,9 +22,25 @@ function toggleDropdown() {
<span class="flex-1 ml-3 text-left whitespace-nowrap">
<slot name="trigger" />
</span>
<slot name="arrow-icon" :toggle-dropdown="toggleDropdown">
<svg class="w-3 h-3 transition-all duration-300" :class="isOpen && 'rotate-180'" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
<slot
name="arrow-icon"
:toggle-dropdown="toggleDropdown"
>
<svg
class="w-3 h-3 transition-all duration-300"
:class="isOpen && 'rotate-180'"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 10 6"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 4 4 4-4"
/>
</svg>
</slot>
</button>
@@ -54,3 +62,11 @@ function toggleDropdown() {
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const isOpen = ref(false)
function toggleDropdown () {
isOpen.value = !isOpen.value
}
</script>

View File

@@ -1,3 +1,20 @@
<template>
<component
:is="component"
:[linkAttr]="link"
class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"
>
<slot name="icon" />
<span
class="flex-1 whitespace-nowrap"
:class="$slots.icon && 'ml-3'"
>
<slot name="default" />
</span>
<slot name="suffix" />
</component>
</template>
<script setup lang="ts">
import { resolveComponent } from 'vue'
const props = withDefaults(
@@ -14,13 +31,3 @@ const props = withDefaults(
const component = props.tag === 'a' ? 'a' : resolveComponent(props.tag)
const linkAttr = props.tag === 'a' ? 'href' : 'to'
</script>
<template>
<component :is="component" :[linkAttr]="link" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
<slot name="icon" />
<span class="flex-1 whitespace-nowrap" :class="$slots.icon && 'ml-3'">
<slot name="default" />
</span>
<slot name="suffix" />
</component>
</template>

View File

@@ -1,3 +1,9 @@
<template>
<div :class="border && borderClasses">
<slot name="default" />
</div>
</template>
<script setup lang="ts">
const borderClasses = 'pt-4 mt-4 space-y-2 font-medium border-t border-gray-200 dark:border-gray-700'
withDefaults(
@@ -9,9 +15,3 @@ withDefaults(
},
)
</script>
<template>
<div :class="border && borderClasses">
<slot name="default" />
</div>
</template>

View File

@@ -1,3 +1,18 @@
<template>
<component
:is="component"
:[linkAttr]="link"
class="flex items-center mb-5 pl-2.5"
>
<img
:src="logo"
class="h-6 mr-3 sm:h-7"
:alt="alt ?? name"
>
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">{{ name }}</span>
</component>
</template>
<script setup lang="ts">
import { resolveComponent } from 'vue'
@@ -14,16 +29,10 @@ const props = withDefaults(
link: '/',
logo: '',
tag: 'router-link',
alt: '',
},
)
const component = props.tag === 'a' ? 'a' : resolveComponent(props.tag)
const linkAttr = props.tag === 'a' ? 'href' : 'to'
</script>
<template>
<component :is="component" :[linkAttr]="link" class="flex items-center mb-5 pl-2.5">
<img :src="logo" class="h-6 mr-3 sm:h-7" :alt="alt ?? name" />
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">{{ name }}</span>
</component>
</template>

View File

@@ -0,0 +1,36 @@
<template>
<svg
:class="spinnerClasses"
fill="none"
role="status"
viewBox="0 0 100 101"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</template>
<script lang="ts" setup>
import { toRefs } from 'vue'
import { useSpinnerClasses } from './composables/useSpinnerClasses'
import type { SpinnerColor, SpinnerSize } from './types'
interface ISpinnerProps {
color?: SpinnerColor,
size?: SpinnerSize
}
const props = withDefaults(defineProps<ISpinnerProps>(), {
color: 'blue',
size: '4',
})
const { spinnerClasses } = useSpinnerClasses(toRefs(props))
</script>

View File

@@ -0,0 +1,53 @@
import { computed, type Ref } from 'vue'
import classNames from 'classnames'
import type { SpinnerColor, SpinnerSize } from '../types'
const sizes: Record<SpinnerSize, string> = {
0: 'w-0 h-0',
0.5: 'w-0.5 h-0.5',
1: 'w-1 h-1',
1.5: 'w-1.5 h-1.5',
10: 'w-10 h-10',
11: 'w-11 h-11',
12: 'w-12 h-12',
2: 'w-2 h-2',
2.5: 'w-2.5 h-2.5',
3: 'w-3 h-3',
4: 'w-4 h-4',
5: 'w-5 h-5',
6: 'w-6 h-6',
7: 'w-7 h-7',
8: 'w-8 h-8',
9: 'w-9 h-9',
}
const colors: Record<SpinnerColor, string> = {
blue: 'fill-blue-600',
gray: 'fill-gray-600 dark:fill-gray-300',
green: 'fill-green-500',
pink: 'fill-pink-600',
purple: 'fill-purple-600',
red: 'fill-red-600',
white: 'fill-white',
yellow: 'fill-yellow-400',
}
export type UseSpinnerClassesProps = {
color: Ref<SpinnerColor>
size: Ref<SpinnerSize>
}
export function useSpinnerClasses (props: UseSpinnerClassesProps): { spinnerClasses: Ref<string> } {
const sizeClasses = computed(() => sizes[props.size.value])
const colorClasses = computed(() => colors[props.color.value])
const bgColorClasses = computed(() => 'text-gray-200 dark:text-gray-600')
const animateClasses = computed(() => 'animate-spin')
const spinnerClasses = computed(() => classNames(
animateClasses.value,
bgColorClasses.value,
colorClasses.value,
sizeClasses.value,
))
return { spinnerClasses }
}

View File

@@ -0,0 +1,3 @@
export type SpinnerColor = 'blue' | 'gray' | 'green' | 'red' | 'yellow' | 'pink' | 'purple' | 'white'
export type SpinnerSize = '0' | '0.5' | '1' | '1.5' | '2' | '2.5' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12'

Some files were not shown because too many files have changed in this diff Show More