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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +0,0 @@
|
||||
export type PictureItem = {
|
||||
src: string,
|
||||
alt?: string
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export type AlertType = 'info' | 'danger' | 'success' | 'warning' | 'dark'
|
||||
export type AlertType = 'info' | 'danger' | 'success' | 'warning' | 'dark'
|
||||
108
src/components/FwbAvatar/FwbAvatar.vue
Normal file
108
src/components/FwbAvatar/FwbAvatar.vue
Normal 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>
|
||||
19
src/components/FwbAvatar/FwbAvatarStackCounter.vue
Normal file
19
src/components/FwbAvatar/FwbAvatarStackCounter.vue
Normal 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>
|
||||
113
src/components/FwbAvatar/composables/useAvatarClasses.ts
Normal file
113
src/components/FwbAvatar/composables/useAvatarClasses.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
@@ -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>
|
||||
55
src/components/FwbBreadcrumb/FwbBreadcrumbItem.vue
Normal file
55
src/components/FwbBreadcrumb/FwbBreadcrumbItem.vue
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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(() => {
|
||||
51
src/components/FwbButton/composables/useButtonSpinner.ts
Normal file
51
src/components/FwbButton/composables/useButtonSpinner.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
45
src/components/FwbButton/tests/Button.spec.ts
Normal file
45
src/components/FwbButton/tests/Button.spec.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
33
src/components/FwbCard/composables/useCardClasses.ts
Normal file
33
src/components/FwbCard/composables/useCardClasses.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
164
src/components/FwbCarousel/FwbCarousel.vue
Normal file
164
src/components/FwbCarousel/FwbCarousel.vue
Normal 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>
|
||||
4
src/components/FwbCarousel/types.ts
Normal file
4
src/components/FwbCarousel/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type PictureItem = {
|
||||
alt?: string
|
||||
src: string,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
150
src/components/FwbDropdown/FwbDropdown.vue
Normal file
150
src/components/FwbDropdown/FwbDropdown.vue
Normal 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>
|
||||
67
src/components/FwbDropdown/composables/useDropdownClasses.ts
Normal file
67
src/components/FwbDropdown/composables/useDropdownClasses.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export type DropdownPlacement = 'top' | 'bottom' | 'left' | 'right'
|
||||
export type DropdownPlacement = 'top' | 'bottom' | 'left' | 'right'
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,9 +1,22 @@
|
||||
<template>
|
||||
<span
|
||||
v-bind="$attrs"
|
||||
:class="spanClasses"
|
||||
>
|
||||
© {{ 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">
|
||||
© {{ year }}
|
||||
<component :is="byComponent" :href="href" :class="aClasses">{{ by }}</component>
|
||||
{{ copyrightMessage }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
86
src/components/FwbInput/FwbInput.vue
Normal file
86
src/components/FwbInput/FwbInput.vue
Normal 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>
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
10
src/components/FwbInput/types.ts
Normal file
10
src/components/FwbInput/types.ts
Normal 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]
|
||||
@@ -3,6 +3,7 @@
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useListGroupClasses } from './composables/useListGroupClasses'
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
114
src/components/FwbModal/FwbModal.vue
Normal file
114
src/components/FwbModal/FwbModal.vue
Normal 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>
|
||||
83
src/components/FwbNavbar/FwbNavbar.vue
Normal file
83
src/components/FwbNavbar/FwbNavbar.vue
Normal 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>
|
||||
@@ -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>
|
||||
0
src/components/FwbNavbar/FwbNavbarLink.ts
Normal file
0
src/components/FwbNavbar/FwbNavbarLink.ts
Normal file
50
src/components/FwbNavbar/FwbNavbarLink.vue
Normal file
50
src/components/FwbNavbar/FwbNavbarLink.vue
Normal 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>
|
||||
38
src/components/FwbNavbar/FwbNavbarLogo.vue
Normal file
38
src/components/FwbNavbar/FwbNavbarLogo.vue
Normal 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>
|
||||
@@ -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'
|
||||
61
src/components/FwbProgress/FwbProgress.vue
Normal file
61
src/components/FwbProgress/FwbProgress.vue
Normal 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>
|
||||
@@ -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],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
54
src/components/FwbRadio/FwbRadio.vue
Normal file
54
src/components/FwbRadio/FwbRadio.vue
Normal 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>
|
||||
66
src/components/FwbRange/FwbRange.vue
Normal file
66
src/components/FwbRange/FwbRange.vue
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
1
src/components/FwbRating/types.ts
Normal file
1
src/components/FwbRating/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type RatingSize = 'sm' | 'md' | 'lg'
|
||||
86
src/components/FwbSelect/FwbSelect.vue
Normal file
86
src/components/FwbSelect/FwbSelect.vue
Normal 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>
|
||||
@@ -1,4 +1,4 @@
|
||||
export type OptionsType = {
|
||||
value: string,
|
||||
name: string,
|
||||
value: string,
|
||||
}
|
||||
@@ -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 />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
36
src/components/FwbSpinner/FwbSpinner.vue
Normal file
36
src/components/FwbSpinner/FwbSpinner.vue
Normal 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>
|
||||
53
src/components/FwbSpinner/composables/useSpinnerClasses.ts
Normal file
53
src/components/FwbSpinner/composables/useSpinnerClasses.ts
Normal 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 }
|
||||
}
|
||||
3
src/components/FwbSpinner/types.ts
Normal file
3
src/components/FwbSpinner/types.ts
Normal 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
Reference in New Issue
Block a user