mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-02-01 20:57:57 +01:00
chore: put back components
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="mx-auto max-w-80em">
|
||||||
Welcome
|
Welcome
|
||||||
<NuButton class="ml-3" variant="primary" icon="heroicons-outline:check-circle">
|
<NuButton class="ml-3" variant="primary" icon="heroicons-outline:check-circle">
|
||||||
toto
|
toto
|
||||||
|
|||||||
165
src/components/elements/Avatar.vue
Normal file
165
src/components/elements/Avatar.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<span class="relative inline-flex items-center justify-center overflow-hidden" :class="avatarClass" @click="goto">
|
||||||
|
<img v-if="url" :src="url" :alt="alt" :class="roundedClass" />
|
||||||
|
<span v-else-if="gradientPlaceholder" class="w-full h-full" v-html="gradientPlaceholder" />
|
||||||
|
<span
|
||||||
|
v-else-if="placeholder"
|
||||||
|
class="font-bold leading-none text-white uppercase"
|
||||||
|
>{{ placeholder }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="text"
|
||||||
|
class="leading-snug"
|
||||||
|
>{{ text }}</span>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="w-full h-full text-tw-gray-300"
|
||||||
|
:class="roundedClass"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="status"
|
||||||
|
class="absolute top-0 right-0 block rounded-full ring-1 ring-white dark:ring-gray-900"
|
||||||
|
:class="statusClass"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import avatar from 'gradient-avatar'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
src: {
|
||||||
|
type: [String, Boolean],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['xxxs', 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
gradient: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
validator (value) {
|
||||||
|
return ['online', 'idle', 'invisible', 'donotdisturb', 'focus'].includes(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
url () {
|
||||||
|
if (typeof this.src === 'boolean') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.src
|
||||||
|
},
|
||||||
|
placeholder () {
|
||||||
|
if (!this.alt) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.alt.split(' ').map(word => word.charAt(0)).join('')
|
||||||
|
},
|
||||||
|
gradientPlaceholder () {
|
||||||
|
if (!this.gradient) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return avatar(this.alt || new Date().toString())
|
||||||
|
},
|
||||||
|
sizeClass () {
|
||||||
|
return ({
|
||||||
|
xxxs: 'h-4 w-4 text-xs',
|
||||||
|
xxs: 'h-5 w-5 text-xs',
|
||||||
|
xs: 'h-6 w-6 text-xs',
|
||||||
|
sm: 'h-8 w-8 text-sm',
|
||||||
|
md: 'h-10 w-10 text-md',
|
||||||
|
lg: 'h-12 w-12 text-lg',
|
||||||
|
xl: 'h-14 w-14 text-xl',
|
||||||
|
'2xl': 'h-16 w-16 text-2xl',
|
||||||
|
'3xl': 'h-20 w-20 text-3xl'
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
roundedClass () {
|
||||||
|
return ({
|
||||||
|
true: 'rounded-lg',
|
||||||
|
false: 'rounded-full'
|
||||||
|
})[this.rounded]
|
||||||
|
},
|
||||||
|
placeholderClass () {
|
||||||
|
return ({
|
||||||
|
true: 'bg-tw-white',
|
||||||
|
false: 'bg-tw-gray-100'
|
||||||
|
})[!!this.alt]
|
||||||
|
},
|
||||||
|
avatarClass () {
|
||||||
|
return [
|
||||||
|
this.sizeClass,
|
||||||
|
this.roundedClass,
|
||||||
|
this.placeholderClass,
|
||||||
|
this.to ? 'cursor-pointer' : ''
|
||||||
|
].join(' ')
|
||||||
|
},
|
||||||
|
statusClass () {
|
||||||
|
return [
|
||||||
|
({
|
||||||
|
online: 'bg-green-400',
|
||||||
|
idle: 'bg-yellow-400',
|
||||||
|
invisible: 'bg-tw-gray-300',
|
||||||
|
donotdisturb: 'bg-red-400',
|
||||||
|
focus: 'bg-primary-500'
|
||||||
|
})[this.status],
|
||||||
|
({
|
||||||
|
xxxs: 'h-1 w-1',
|
||||||
|
xxs: 'h-1 w-1',
|
||||||
|
xs: 'h-1.5 w-1.5',
|
||||||
|
sm: 'h-2 w-2',
|
||||||
|
md: 'h-2.5 w-2.5',
|
||||||
|
lg: 'h-3 w-3',
|
||||||
|
xl: 'h-3.5 w-3.5',
|
||||||
|
'2xl': 'h-3.5 w-3.5',
|
||||||
|
'3xl': 'h-4 w-4'
|
||||||
|
})[this.size],
|
||||||
|
({
|
||||||
|
true: 'transform -translate-y-1/2 translate-x-1/2'
|
||||||
|
})[this.rounded]
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goto (e) {
|
||||||
|
if (!this.to || !this.$router) { return }
|
||||||
|
e.preventDefault()
|
||||||
|
this.$router.push(this.to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
53
src/components/elements/AvatarGroup.vue
Normal file
53
src/components/elements/AvatarGroup.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex">
|
||||||
|
<TwAvatar
|
||||||
|
v-for="(avatar, index) of displayedGroup"
|
||||||
|
:key="index"
|
||||||
|
:src="avatar.src"
|
||||||
|
class="shadow-solid -ml-1.5 first:ml-0"
|
||||||
|
:size="size"
|
||||||
|
:status="avatar.status"
|
||||||
|
/>
|
||||||
|
<TwAvatar
|
||||||
|
v-if="remainingGroupSize > 0"
|
||||||
|
class="shadow-solid -ml-1.5 first:ml-0 text-[10px]"
|
||||||
|
:size="size"
|
||||||
|
:text="`+${remainingGroupSize}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
group: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['xxxs', 'xxs', 'xs', 'sm', 'md', 'lg', 'xl'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
displayedGroup () {
|
||||||
|
if (!this.max) { return this.group }
|
||||||
|
|
||||||
|
return this.group.slice(0, this.max)
|
||||||
|
},
|
||||||
|
remainingGroupSize () {
|
||||||
|
if (!this.max) { return 0 }
|
||||||
|
|
||||||
|
return this.group.length - this.max
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
72
src/components/elements/Badge.vue
Normal file
72
src/components/elements/Badge.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<span class="inline-flex items-center font-medium" :class="badgeClass">
|
||||||
|
<slot>{{ label }}</slot>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['sm', 'md', 'lg'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'primary',
|
||||||
|
validator (value) {
|
||||||
|
return ['primary', 'gray', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'indigo', 'purple', 'pink', 'gradient'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pill: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sizeClass () {
|
||||||
|
return ({
|
||||||
|
sm: 'text-xs px-2 py-0.5',
|
||||||
|
md: 'text-sm px-2.5 py-0.5 leading-4',
|
||||||
|
lg: 'text-sm px-3 py-0.5 leading-5'
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
variantClass () {
|
||||||
|
return ({
|
||||||
|
primary: 'bg-primary-100 dark:bg-primary-700 text-primary-800 dark:text-primary-100',
|
||||||
|
gray: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100',
|
||||||
|
red: 'bg-red-100 dark:bg-red-700 text-red-800 dark:text-red-100',
|
||||||
|
orange: 'bg-orange-100 dark:bg-orange-700 text-orange-800 dark:text-orange-100',
|
||||||
|
yellow: 'bg-yellow-100 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-100',
|
||||||
|
green: 'bg-green-100 dark:bg-green-700 text-green-800 dark:text-green-100',
|
||||||
|
teal: 'bg-teal-100 dark:bg-teal-700 text-teal-800 dark:text-teal-100',
|
||||||
|
blue: 'bg-blue-100 dark:bg-blue-700 text-blue-800 dark:text-blue-100',
|
||||||
|
indigo: 'bg-indigo-100 dark:bg-indigo-700 text-indigo-800 dark:text-indigo-100',
|
||||||
|
purple: 'bg-purple-100 dark:bg-purple-700 text-purple-800 dark:text-purple-100',
|
||||||
|
pink: 'bg-pink-100 dark:bg-pink-700 text-pink-800 dark:text-pink-100',
|
||||||
|
gradient: 'bg-gradient-to-r from-indigo-600 to-blue-600 text-white'
|
||||||
|
})[this.variant]
|
||||||
|
},
|
||||||
|
roundedClass () {
|
||||||
|
return ({
|
||||||
|
false: 'rounded',
|
||||||
|
true: 'rounded-full'
|
||||||
|
})[this.pill]
|
||||||
|
},
|
||||||
|
badgeClass () {
|
||||||
|
return [
|
||||||
|
this.sizeClass,
|
||||||
|
this.variantClass,
|
||||||
|
this.roundedClass
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -12,12 +12,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Icon from './Icon'
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: { Icon },
|
||||||
Icon
|
|
||||||
},
|
|
||||||
props: {
|
props: {
|
||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
327
src/components/elements/Dropdown.vue
Normal file
327
src/components/elements/Dropdown.vue
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="container"
|
||||||
|
v-on-clickaway="close"
|
||||||
|
:class="typeof wrapperClass === 'string' ? wrapperClass : 'relative inline-block text-left'"
|
||||||
|
@keydown.escape="open = false"
|
||||||
|
@mouseover="mode === 'hover' ? mouseover() : () => {}"
|
||||||
|
@mouseleave="mode === 'hover' ? mouseleave() : () => {}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<slot name="trigger" :toggle="toggle" :open="open">
|
||||||
|
<TwButton
|
||||||
|
:variant="variant"
|
||||||
|
:size="size"
|
||||||
|
:label="label"
|
||||||
|
:icon="icon"
|
||||||
|
:rounded="rounded"
|
||||||
|
:square="square"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="disabled"
|
||||||
|
:custom-class="customClass"
|
||||||
|
:icon-class="iconClass"
|
||||||
|
trailing
|
||||||
|
@click.native="toggle"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TwButton>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
appear
|
||||||
|
enter-class="transform scale-95 opacity-0"
|
||||||
|
enter-active-class="transition duration-100 ease-out"
|
||||||
|
enter-to-class="transform scale-100 opacity-100"
|
||||||
|
leave-class="transform scale-100 opacity-100"
|
||||||
|
leave-active-class="transition duration-75 ease-in"
|
||||||
|
leave-to-class="transform scale-95 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-show="open && (links.length || $slots.header || $scopedSlots.header)"
|
||||||
|
ref="tooltip"
|
||||||
|
:class="[
|
||||||
|
dropdownClass,
|
||||||
|
mode === 'hover' && 'pt-2'
|
||||||
|
]"
|
||||||
|
class="z-30 rounded-md shadow-lg"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="overflow-y-auto bg-white divide-y divide-gray-100 rounded-md ring-1 ring-gray-200 dark:ring-gray-700 dark:divide-gray-700"
|
||||||
|
:class="[
|
||||||
|
dropdownMenuClass,
|
||||||
|
darken ? 'dark:bg-gray-900' : 'dark:bg-gray-800'
|
||||||
|
]"
|
||||||
|
role="menu"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-labelledby="options-menu"
|
||||||
|
>
|
||||||
|
<slot name="header" />
|
||||||
|
<div v-if="links.length" class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<div v-for="(items, index) of links" :key="index" class="py-1">
|
||||||
|
<div
|
||||||
|
v-for="(link, i) of items"
|
||||||
|
:key="i"
|
||||||
|
role="menuitem"
|
||||||
|
@click="!link.disabled ? click(link) : (() => {})()"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="(link.to && 'NuxtLink') || (link.href && 'a') || 'div'"
|
||||||
|
:to="link.to"
|
||||||
|
:href="link.href"
|
||||||
|
:target="link.target"
|
||||||
|
:class="[
|
||||||
|
link.click || (link.to || link.href) ? 'cursor-pointer hover:text-tw-gray-900 focus:text-tw-gray-900 hover:bg-tw-gray-100 focus:bg-tw-gray-100' : '',
|
||||||
|
link.active ? 'bg-tw-gray-100' : '',
|
||||||
|
!link.disabled ? 'text-tw-gray-600' : 'text-tw-gray-400 cursor-not-allowed',
|
||||||
|
size === 'md' ? 'px-4 py-2' : 'px-3 py-1.5',
|
||||||
|
customLinkClass,
|
||||||
|
link.customClass
|
||||||
|
]"
|
||||||
|
class="flex items-center justify-between space-x-3 text-sm group focus:outline-none"
|
||||||
|
>
|
||||||
|
<slot name="link" :link="link">
|
||||||
|
<div class="flex items-center flex-1 truncate" :class="{ 'flex-row-reverse justify-between': link.reverse, 'gap-3': size === 'md', 'gap-2': size !== 'md' }">
|
||||||
|
<Icon
|
||||||
|
v-if="link.icon"
|
||||||
|
:name="link.icon"
|
||||||
|
:class="[
|
||||||
|
!link.disabled ? 'group-hover:text-tw-gray-500 group-focus:text-tw-gray-500' : '',
|
||||||
|
size === 'md' ? 'h-5 w-5' : 'h-4 w-4',
|
||||||
|
link.customIconClass
|
||||||
|
]"
|
||||||
|
class="flex-shrink-0 text-tw-gray-400"
|
||||||
|
/>
|
||||||
|
<TwAvatar
|
||||||
|
v-if="link.avatar"
|
||||||
|
:src="link.avatar"
|
||||||
|
:alt="link.label"
|
||||||
|
:gradient="link.gradient"
|
||||||
|
size="xxs"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ link.label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="link.shortcuts && link.shortcuts.length && $mq !== 'xs'"
|
||||||
|
class="flex items-center flex-shrink-0 ml-1 space-x-1 text-xs font-bold text-tw-gray-400"
|
||||||
|
>
|
||||||
|
<div v-for="(shortcut, j) of link.shortcuts" :key="j">
|
||||||
|
<span>
|
||||||
|
{{ shortcut }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="link.unread"
|
||||||
|
class="flex-shrink-0 block w-2 h-2 p-1 mr-1 rounded-full opacity-75 bg-primary-600 dark:bg-white animate-pulse"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<slot v-if="link.slot" :name="link.slot" />
|
||||||
|
</component>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { createPopper } from '@popperjs/core'
|
||||||
|
import { directive as onClickaway } from 'vue-clickaway'
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
directives: {
|
||||||
|
onClickaway
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: 'solid/chevron-down'
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'white'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['', 'xxs', 'xs', 'sm', 'md', 'lg', 'xl'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'click',
|
||||||
|
validator (value) {
|
||||||
|
return ['click', 'hover'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String,
|
||||||
|
default: 'bottom-end'
|
||||||
|
},
|
||||||
|
strategy: {
|
||||||
|
type: String,
|
||||||
|
default: 'absolute'
|
||||||
|
},
|
||||||
|
wrapperClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
iconClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
customClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
customLinkClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
square: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
darken: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
dropdownClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'w-48'
|
||||||
|
},
|
||||||
|
dropdownMenuClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'max-h-56'
|
||||||
|
},
|
||||||
|
openDelay: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
},
|
||||||
|
closeDelay: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
preventOverflow: {
|
||||||
|
type: Number,
|
||||||
|
default: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
open: false,
|
||||||
|
instance: null,
|
||||||
|
openTimeout: null,
|
||||||
|
closeTimeout: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
disabled (value) {
|
||||||
|
if (value && open) { this.close() }
|
||||||
|
},
|
||||||
|
open (value) {
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.destroy()
|
||||||
|
this.instance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.instance = createPopper(this.$refs.container, this.$refs.tooltip, {
|
||||||
|
strategy: this.strategy,
|
||||||
|
placement: this.placement,
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: 'offset',
|
||||||
|
options: {
|
||||||
|
offset: this.mode === 'click' ? [0, 8] : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'computeStyles',
|
||||||
|
options: {
|
||||||
|
gpuAcceleration: false,
|
||||||
|
adaptive: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preventOverflow',
|
||||||
|
options: {
|
||||||
|
padding: this.preventOverflow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.destroy()
|
||||||
|
this.instance = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
click (link) {
|
||||||
|
if (link.click) {
|
||||||
|
link.click()
|
||||||
|
}
|
||||||
|
if (link.click || link.to) {
|
||||||
|
this.toggle()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseover () {
|
||||||
|
clearTimeout(this.closeTimeout)
|
||||||
|
this.closeTimeout = null
|
||||||
|
this.openTimeout = this.openTimeout || setTimeout(() => {
|
||||||
|
this.open = true
|
||||||
|
this.openTimeout = null
|
||||||
|
}, this.openDelay)
|
||||||
|
},
|
||||||
|
mouseleave () {
|
||||||
|
clearTimeout(this.openTimeout)
|
||||||
|
this.openTimeout = null
|
||||||
|
this.closeTimeout = this.closeTimeout || setTimeout(() => {
|
||||||
|
this.close()
|
||||||
|
this.closeTimeout = null
|
||||||
|
}, this.closeDelay)
|
||||||
|
},
|
||||||
|
toggle () {
|
||||||
|
this.open = !this.open
|
||||||
|
},
|
||||||
|
close () {
|
||||||
|
this.open = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
44
src/components/elements/Link.vue
Normal file
44
src/components/elements/Link.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink v-slot="{ href, navigate }" v-bind="$props" custom>
|
||||||
|
<a
|
||||||
|
v-bind="$attrs"
|
||||||
|
:href="href"
|
||||||
|
:class="isActive ? activeClass : inactiveClass"
|
||||||
|
v-on="$listeners"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
<slot v-bind="{ isActive }" />
|
||||||
|
</a>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
const RouterLink = Vue.component('RouterLink')
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Link',
|
||||||
|
props: {
|
||||||
|
// @ts-ignore
|
||||||
|
...RouterLink.options.props,
|
||||||
|
inactiveClass: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
exact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isActive () {
|
||||||
|
if (!this.exact) {
|
||||||
|
return !!this.$route.path.startsWith(this.to)
|
||||||
|
} else {
|
||||||
|
return this.$route.path === this.to || this.$route.path === `${this.to}/`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
125
src/components/elements/Toggle.vue
Normal file
125
src/components/elements/Toggle.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center"
|
||||||
|
:class="wrapperClass"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="textLeading"
|
||||||
|
:class="textClass"
|
||||||
|
>{{ textLeading }}</span>
|
||||||
|
<button
|
||||||
|
v-if="!short"
|
||||||
|
type="button"
|
||||||
|
aria-pressed="false"
|
||||||
|
:class="value ? 'bg-primary-600' : 'bg-tw-gray-200'"
|
||||||
|
class="relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
:class="value ? 'translate-x-5 text-primary-600' : 'translate-x-0 text-tw-gray-400'"
|
||||||
|
class="inline-flex items-center justify-center h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
|
||||||
|
>
|
||||||
|
<Icon v-if="icon" :name="value ? icon : (iconOff ? iconOff : icon)" class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
aria-pressed="false"
|
||||||
|
class="flex-shrink-0 group relative rounded-full inline-flex items-center justify-center h-5 w-10 cursor-pointer focus:outline-none"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
:class="value ? 'bg-primary-600' : 'bg-tw-gray-200'"
|
||||||
|
class="absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
:class="value ? 'translate-x-5 text-primary-600' : 'translate-x-0 text-tw-gray-400'"
|
||||||
|
class="absolute left-0 flex items-center justify-center h-5 w-5 border border-tw-gray-200 rounded-full bg-white shadow transform ring-0 transition-transform ease-in-out duration-200"
|
||||||
|
>
|
||||||
|
<Icon v-if="icon" :name="value ? icon : (iconOff ? iconOff : icon)" class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="textTrailing"
|
||||||
|
:class="textClass"
|
||||||
|
>{{ textTrailing }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
iconOff: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
short: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
textLeading: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
textTrailing: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
textHighlight: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['xs', 'sm', 'md', 'lg', 'xl'].includes(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
wrapperClass () {
|
||||||
|
return ({
|
||||||
|
xs: 'space-x-2',
|
||||||
|
sm: 'space-x-3',
|
||||||
|
md: 'space-x-3',
|
||||||
|
lg: 'space-x-3',
|
||||||
|
xl: 'space-x-3'
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
textClass () {
|
||||||
|
return [
|
||||||
|
({
|
||||||
|
xs: 'text-xs',
|
||||||
|
sm: 'text-sm',
|
||||||
|
md: 'text-sm',
|
||||||
|
lg: 'text-base',
|
||||||
|
xl: 'text-base'
|
||||||
|
})[this.size],
|
||||||
|
this.textHighlight && this.value ? 'text-primary-600' : 'text-tw-gray-900',
|
||||||
|
'font-medium'
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onClick () {
|
||||||
|
this.$emit('input', !this.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
94
src/components/feedback/Alert.vue
Normal file
94
src/components/feedback/Alert.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-md p-4" :class="variantClass">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<Icon :name="iconName" :class="iconClass" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||||
|
<p v-if="title" class="text-sm leading-5" :class="titleClass">
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
<p v-if="link" class="mt-3 text-sm leading-5 md:mt-0 md:ml-6">
|
||||||
|
<NuxtLink
|
||||||
|
:to="to"
|
||||||
|
class="whitespace-nowrap font-medium"
|
||||||
|
:class="linkClass"
|
||||||
|
>
|
||||||
|
{{ link }} →
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
props: {
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'info',
|
||||||
|
validator (value) {
|
||||||
|
return ['info', 'warning', 'error', 'success'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
iconName () {
|
||||||
|
return ({
|
||||||
|
info: 'solid/information-circle',
|
||||||
|
warning: 'solid/exclamation',
|
||||||
|
error: 'solid/x-circle',
|
||||||
|
success: 'solid/check-circle'
|
||||||
|
})[this.variant]
|
||||||
|
},
|
||||||
|
variantClass () {
|
||||||
|
return ({
|
||||||
|
info: 'bg-blue-50',
|
||||||
|
warning: 'bg-orange-50',
|
||||||
|
error: 'bg-red-50',
|
||||||
|
success: 'bg-green-50'
|
||||||
|
})[this.variant]
|
||||||
|
},
|
||||||
|
iconClass () {
|
||||||
|
return ({
|
||||||
|
info: 'text-blue-400',
|
||||||
|
warning: 'text-orange-400',
|
||||||
|
error: 'text-red-400',
|
||||||
|
success: 'text-green-400'
|
||||||
|
})[this.variant]
|
||||||
|
},
|
||||||
|
titleClass () {
|
||||||
|
return ({
|
||||||
|
info: 'text-blue-700',
|
||||||
|
warning: 'text-orange-700',
|
||||||
|
error: 'text-red-700',
|
||||||
|
success: 'text-green-700'
|
||||||
|
})[this.variant]
|
||||||
|
},
|
||||||
|
linkClass () {
|
||||||
|
return ({
|
||||||
|
info: 'text-blue-700 hover:text-blue-600',
|
||||||
|
warning: 'text-orange-700 hover:text-orange-600',
|
||||||
|
error: 'text-red-700 hover:text-red-600',
|
||||||
|
success: 'text-green-700 hover:text-green-600'
|
||||||
|
})[this.variant]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
103
src/components/forms/Checkbox.vue
Normal file
103
src/components/forms/Checkbox.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative flex" :class="{ 'items-start': label, 'items-center': !label }">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
:id="name"
|
||||||
|
:checked="isChecked"
|
||||||
|
:name="name"
|
||||||
|
:required="required"
|
||||||
|
:value="value"
|
||||||
|
:disabled="disabled"
|
||||||
|
type="checkbox"
|
||||||
|
:class="inputClass"
|
||||||
|
@focus="focused = true"
|
||||||
|
@blur="focused = false"
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="label" class="ml-3 text-sm">
|
||||||
|
<label :for="name" class="font-medium text-tw-gray-700">
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="required" class="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<p v-if="help" class="text-tw-gray-500">{{ help }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
model: {
|
||||||
|
prop: 'checked',
|
||||||
|
event: 'change'
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: [String, Number, Boolean],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
checked: {
|
||||||
|
type: [Array, Boolean],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
baseClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'h-4 w-4 text-primary-600 focus:ring-primary-500 border-tw-gray-300 bg-white dark:bg-gray-800 dark:checked:bg-primary-600 dark:checked:border-primary-600 focus:ring-offset-0 disabled:opacity-50 disabled:cursor-not-allowed rounded'
|
||||||
|
},
|
||||||
|
customClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
focused: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isChecked () {
|
||||||
|
return Array.isArray(this.checked) ? this.checked.includes(this.value) : this.checked
|
||||||
|
},
|
||||||
|
inputClass () {
|
||||||
|
return [
|
||||||
|
this.baseClass,
|
||||||
|
this.customClass
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onChange () {
|
||||||
|
// We check if we have validation error and clean it as the user as typed a new value
|
||||||
|
if (this.newValidation) { this.newValidation = null }
|
||||||
|
|
||||||
|
if (!Array.isArray(this.checked)) { return this.$emit('change', !this.checked) }
|
||||||
|
|
||||||
|
if (this.checked.includes(this.value)) { this.$emit('change', this.checked.filter(c => c !== this.value)) } else { this.$emit('change', this.checked.concat(this.value)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
253
src/components/forms/Input.vue
Normal file
253
src/components/forms/Input.vue
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="wrapperClass">
|
||||||
|
<div
|
||||||
|
v-if="isLeading"
|
||||||
|
class="absolute inset-y-0 left-0 flex items-center pointer-events-none"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="iconName"
|
||||||
|
class="text-tw-gray-400"
|
||||||
|
:class="iconClass"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
:id="name"
|
||||||
|
ref="input"
|
||||||
|
v-focus="autofocus"
|
||||||
|
:name="name"
|
||||||
|
:value="value"
|
||||||
|
:type="type"
|
||||||
|
:required="required"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:spellcheck="spellcheck"
|
||||||
|
:class="inputClass"
|
||||||
|
@input="updateValue($event.target.value)"
|
||||||
|
@focus="$emit('focus', $event)"
|
||||||
|
@blur="$emit('blur', $event)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<div
|
||||||
|
v-if="isTrailing"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center pointer-events-none"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="iconName"
|
||||||
|
class="text-tw-gray-400"
|
||||||
|
:class="iconClass"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Focus from '../../directives/focus'
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
directives: {
|
||||||
|
focus: Focus
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
autocomplete: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
spellcheck: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
loadingIcon: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
trailing: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
leading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['', 'xxs', 'xs', 'sm', 'md', 'lg', 'xl'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
baseClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'block w-full bg-tw-white disabled:cursor-not-allowed disabled:bg-tw-gray-50'
|
||||||
|
},
|
||||||
|
customClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
validator (value) {
|
||||||
|
return ['default', 'none', 'empty'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sizeClass () {
|
||||||
|
return ({
|
||||||
|
xxs: 'text-xs',
|
||||||
|
xs: 'text-xs',
|
||||||
|
sm: 'text-sm leading-4',
|
||||||
|
md: 'text-sm',
|
||||||
|
lg: 'text-base',
|
||||||
|
xl: 'text-base'
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
paddingClass () {
|
||||||
|
return ({
|
||||||
|
xxs: 'px-1 py-0.5',
|
||||||
|
xs: 'px-2.5 py-1.5',
|
||||||
|
sm: 'px-3 py-2',
|
||||||
|
md: 'px-4 py-2',
|
||||||
|
lg: 'px-4 py-2',
|
||||||
|
xl: 'px-6 py-3'
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
appearanceClass () {
|
||||||
|
return ({
|
||||||
|
default: 'focus:ring-primary-500 focus:border-primary-500 border-tw-gray-300 rounded-md',
|
||||||
|
none: 'border-0 bg-transparent focus:ring-0 focus:shadow-none',
|
||||||
|
empty: ''
|
||||||
|
})[this.appearance]
|
||||||
|
},
|
||||||
|
paddingIconClass () {
|
||||||
|
return [
|
||||||
|
this.isLeading && ({
|
||||||
|
xxs: 'pl-7',
|
||||||
|
xs: 'pl-7',
|
||||||
|
sm: 'pl-10',
|
||||||
|
md: 'pl-10',
|
||||||
|
lg: 'pl-10',
|
||||||
|
xl: 'pl-10'
|
||||||
|
})[this.size],
|
||||||
|
this.isTrailing && ({
|
||||||
|
xxs: 'pr-10',
|
||||||
|
xs: 'pr-10',
|
||||||
|
sm: 'pr-10',
|
||||||
|
md: 'pr-10',
|
||||||
|
lg: 'pr-10',
|
||||||
|
xl: 'pr-10'
|
||||||
|
})[this.size]
|
||||||
|
].join(' ')
|
||||||
|
},
|
||||||
|
inputClass () {
|
||||||
|
return [
|
||||||
|
this.baseClass,
|
||||||
|
this.sizeClass,
|
||||||
|
this.paddingClass,
|
||||||
|
this.paddingIconClass,
|
||||||
|
this.appearanceClass,
|
||||||
|
this.customClass
|
||||||
|
].join(' ')
|
||||||
|
},
|
||||||
|
wrapperClass () {
|
||||||
|
return [
|
||||||
|
'relative',
|
||||||
|
this.appearance !== 'none' ? 'shadow-sm' : ''
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
},
|
||||||
|
isLeading () {
|
||||||
|
return (this.icon && this.leading) || (this.icon && !this.trailing) || (this.loading && !this.trailing)
|
||||||
|
},
|
||||||
|
isTrailing () {
|
||||||
|
return (this.icon && this.trailing) || (this.loading && this.trailing)
|
||||||
|
},
|
||||||
|
iconName () {
|
||||||
|
if (this.loading) {
|
||||||
|
return this.loadingIcon || 'custom/loading'
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.icon
|
||||||
|
},
|
||||||
|
iconClass () {
|
||||||
|
return [
|
||||||
|
({
|
||||||
|
xxs: 'h-3 w-3',
|
||||||
|
xs: 'h-4 w-4',
|
||||||
|
sm: 'h-5 w-5',
|
||||||
|
md: 'h-5 w-5',
|
||||||
|
lg: 'h-5 w-5',
|
||||||
|
xl: 'h-5 w-5'
|
||||||
|
})[this.size || 'sm'],
|
||||||
|
this.isLeading && ({
|
||||||
|
xxs: 'ml-2',
|
||||||
|
xs: 'ml-2',
|
||||||
|
sm: 'ml-3',
|
||||||
|
md: 'ml-3',
|
||||||
|
lg: 'ml-3',
|
||||||
|
xl: 'ml-3'
|
||||||
|
})[this.size || 'sm'],
|
||||||
|
this.isTrailing && ({
|
||||||
|
xxs: 'mr-2',
|
||||||
|
xs: 'mr-2',
|
||||||
|
sm: 'mr-3',
|
||||||
|
md: 'mr-3',
|
||||||
|
lg: 'mr-3',
|
||||||
|
xl: 'mr-3'
|
||||||
|
})[this.size || 'sm'],
|
||||||
|
({
|
||||||
|
true: 'animate-spin'
|
||||||
|
})[this.loading]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateValue (value) {
|
||||||
|
this.$emit('input', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
61
src/components/forms/InputGroup.vue
Normal file
61
src/components/forms/InputGroup.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="{ 'sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-tw-gray-200': inline }"
|
||||||
|
>
|
||||||
|
<slot name="label">
|
||||||
|
<div :class="{ 'flex content-center justify-between': !inline }">
|
||||||
|
<label
|
||||||
|
v-if="label"
|
||||||
|
:for="name"
|
||||||
|
class="block text-sm font-medium leading-5 text-tw-gray-700"
|
||||||
|
:class="{'sm:mt-px sm:pt-2': inline }"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="required" class="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
v-if="$slots.hint || hint"
|
||||||
|
class="text-sm leading-5 text-tw-gray-500"
|
||||||
|
:class="{ 'mt-1 max-w-2xl': inline }"
|
||||||
|
><slot name="hint">{{ hint }}</slot></span>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
<div
|
||||||
|
:class="{ 'mt-1': label && !inline, 'mt-1 sm:mt-0': label && inline, 'sm:col-span-2': inline }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<p v-if="help" class="mt-2 text-sm text-tw-gray-500">{{ help }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
89
src/components/forms/Radio.vue
Normal file
89
src/components/forms/Radio.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<label :for="`${name}-${value}`" class="relative flex cursor-pointer">
|
||||||
|
<input
|
||||||
|
:id="`${name}-${value}`"
|
||||||
|
:checked="checked"
|
||||||
|
:name="name"
|
||||||
|
:required="required"
|
||||||
|
:value="value"
|
||||||
|
:disabled="disabled"
|
||||||
|
type="radio"
|
||||||
|
:class="inputClass"
|
||||||
|
@focus="focused = true"
|
||||||
|
@blur="focused = false"
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
<div v-if="label" class="flex flex-col ml-3">
|
||||||
|
<span class="block text-sm font-medium text-tw-gray-900">
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="required" class="text-red-400">*</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="help" class="block text-sm text-tw-gray-500">{{ help }}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
model: {
|
||||||
|
prop: 'checked',
|
||||||
|
event: 'change'
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: [String, Number, Boolean, Object],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
checked: {
|
||||||
|
type: Boolean,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
baseClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'h-4 w-4 mt-0.5 text-primary-600 checked:border-primary-600 border-tw-gray-300 bg-tw-white dark:checked:bg-primary-600 focus:ring-offset-white dark:focus:ring-offset-gray-900 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
},
|
||||||
|
customClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
focused: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
inputClass () {
|
||||||
|
return [
|
||||||
|
this.baseClass,
|
||||||
|
this.customClass
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onChange () {
|
||||||
|
this.$emit('change', this.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
65
src/components/forms/RadioGroup.vue
Normal file
65
src/components/forms/RadioGroup.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<fieldset :id="name">
|
||||||
|
<legend v-if="label" class="sr-only">{{ label }}</legend>
|
||||||
|
|
||||||
|
<div :class="wrapperClass">
|
||||||
|
<TwRadio
|
||||||
|
v-for="(option, index) in options"
|
||||||
|
:key="index"
|
||||||
|
:checked="option.value === value"
|
||||||
|
:disabled="disabled"
|
||||||
|
:name="name"
|
||||||
|
v-bind="option"
|
||||||
|
:class="inputClass"
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
model: {
|
||||||
|
event: 'change'
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: [String, Number, Boolean, Object],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: [Array],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
wrapperClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
inputClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onChange (value) {
|
||||||
|
this.$emit('change', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
214
src/components/forms/Select.vue
Normal file
214
src/components/forms/Select.vue
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative" :class="wrapperClass">
|
||||||
|
<select
|
||||||
|
:id="name"
|
||||||
|
:name="name"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:readonly="readonly"
|
||||||
|
:class="selectClass"
|
||||||
|
@input="updateValue($event.target.value)"
|
||||||
|
>
|
||||||
|
<template v-for="(option, index) in normalizedOptionsWithPlaceholder">
|
||||||
|
<optgroup
|
||||||
|
v-if="option.children"
|
||||||
|
:key="`${option[valueAttribute]}-optgroup-${index}`"
|
||||||
|
:value="option[valueAttribute]"
|
||||||
|
:label="option[textAttribute]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="(childOption, index2) in option.children"
|
||||||
|
:key="`${childOption[valueAttribute]}-${index}-${index2}`"
|
||||||
|
:value="childOption[valueAttribute]"
|
||||||
|
:selected="childOption[valueAttribute] === normalizedValue"
|
||||||
|
v-text="childOption[textAttribute]"
|
||||||
|
/>
|
||||||
|
</optgroup>
|
||||||
|
<option
|
||||||
|
v-else
|
||||||
|
:key="`${option[valueAttribute]}-${index}`"
|
||||||
|
:value="option[valueAttribute]"
|
||||||
|
:selected="option[valueAttribute] === normalizedValue"
|
||||||
|
v-text="option[textAttribute]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<div v-if="icon" class="absolute inset-y-0 left-0 flex items-center pointer-events-none" :class="iconPadding">
|
||||||
|
<Icon :name="icon" :class="iconClass" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import get from 'lodash/get'
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: [String, Number, Object],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['xxs', 'xs', 'sm', 'md', 'lg', 'xl'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
baseClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'block w-full disabled:cursor-not-allowed bg-tw-white disabled:bg-tw-gray-50 focus:ring-primary-500 focus:border-primary-500 dark:focus:border-primary-500 border-tw-gray-300 rounded-md'
|
||||||
|
},
|
||||||
|
customClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
noShadow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
textAttribute: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
valueAttribute: {
|
||||||
|
type: String,
|
||||||
|
default: 'value'
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sizeClass () {
|
||||||
|
return {
|
||||||
|
xxs: 'text-xs',
|
||||||
|
xs: 'text-xs',
|
||||||
|
sm: 'text-sm leading-4',
|
||||||
|
md: 'text-sm',
|
||||||
|
lg: 'text-base',
|
||||||
|
xl: 'text-base'
|
||||||
|
}[this.size]
|
||||||
|
},
|
||||||
|
paddingClass () {
|
||||||
|
return ({
|
||||||
|
xxs: `${this.icon ? 'pl-7' : 'pl-2'} pr-7 py-1.5`,
|
||||||
|
xs: `${this.icon ? 'pl-8' : 'pl-3'} pr-9 py-1.5`,
|
||||||
|
sm: `${this.icon ? 'pl-8' : 'pl-3'} pr-9 py-2`,
|
||||||
|
md: `${this.icon ? 'pl-10' : 'pl-3'} pr-10 py-2`,
|
||||||
|
lg: `${this.icon ? 'pl-10' : 'pl-3'} pr-10 py-2`,
|
||||||
|
xl: `${this.icon ? 'pl-12' : 'pl-4'} pr-12 py-3`
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
iconClass () {
|
||||||
|
return ({
|
||||||
|
xxs: 'w-3 h-3',
|
||||||
|
xs: 'w-4 h-4',
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-5 h-5',
|
||||||
|
lg: 'w-5 h-5',
|
||||||
|
xl: 'w-5 h-5'
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
iconPadding () {
|
||||||
|
return ({
|
||||||
|
xxs: 'pl-3',
|
||||||
|
xs: 'pl-3',
|
||||||
|
sm: 'pl-3',
|
||||||
|
md: 'pl-3',
|
||||||
|
lg: 'pl-3',
|
||||||
|
xl: 'pl-4'
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
selectClass () {
|
||||||
|
return [
|
||||||
|
this.baseClass,
|
||||||
|
this.customClass,
|
||||||
|
this.sizeClass,
|
||||||
|
this.paddingClass
|
||||||
|
].join(' ')
|
||||||
|
},
|
||||||
|
wrapperClass () {
|
||||||
|
return [
|
||||||
|
'relative',
|
||||||
|
'rounded-md',
|
||||||
|
!this.noShadow ? 'shadow-sm' : ''
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
},
|
||||||
|
normalizedOptions () {
|
||||||
|
return this.options.map(option => this.normalizeOption(option))
|
||||||
|
},
|
||||||
|
normalizedOptionsWithPlaceholder () {
|
||||||
|
if (!this.placeholder) {
|
||||||
|
return this.normalizedOptions
|
||||||
|
}
|
||||||
|
const { normalizedOptions } = this
|
||||||
|
normalizedOptions.unshift({
|
||||||
|
[this.valueAttribute]: null,
|
||||||
|
[this.textAttribute]: this.placeholder
|
||||||
|
})
|
||||||
|
return normalizedOptions
|
||||||
|
},
|
||||||
|
normalizedValue () {
|
||||||
|
const foundOption = this.normalizedOptionsWithPlaceholder.find(option => option.value === this.value)
|
||||||
|
if (!foundOption) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundOption.value
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
guessOptionValue (option) {
|
||||||
|
return get(option, this.valueAttribute, get(option, this.textAttribute))
|
||||||
|
},
|
||||||
|
guessOptionText (option) {
|
||||||
|
return get(option, this.textAttribute, get(option, this.valueAttribute))
|
||||||
|
},
|
||||||
|
normalizeOption (option) {
|
||||||
|
if (['string', 'number', 'boolean'].includes(typeof option)) {
|
||||||
|
return {
|
||||||
|
[this.valueAttribute]: option,
|
||||||
|
[this.textAttribute]: option
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
[this.valueAttribute]: this.guessOptionValue(option),
|
||||||
|
[this.textAttribute]: this.guessOptionText(option)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateValue (value) {
|
||||||
|
this.$emit('input', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
445
src/components/forms/SelectCustom.vue
Normal file
445
src/components/forms/SelectCustom.vue
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="container" v-on-clickaway="close">
|
||||||
|
<input :value="value" :required="required" class="absolute inset-0 w-px opacity-0 cursor-default">
|
||||||
|
|
||||||
|
<slot :toggle="toggle" :open="open">
|
||||||
|
<TwButton
|
||||||
|
icon="solid/selector"
|
||||||
|
icon-class="text-tw-gray-400"
|
||||||
|
trailing
|
||||||
|
:size="size"
|
||||||
|
:variant="variant"
|
||||||
|
base-class="w-full cursor-default focus:outline-none disabled:cursor-not-allowed disabled:opacity-75"
|
||||||
|
:disabled="disabled || !options || !options.length"
|
||||||
|
@click.native="!disabled && options && options.length && toggle()"
|
||||||
|
>
|
||||||
|
<div v-if="selectedOptions && selectedOptions.length" class="inline-flex w-full px-3 py-2 -my-2 -ml-3 truncate">
|
||||||
|
<span v-for="(selectedOption, index) of selectedOptions" :key="index" class="inline-flex items-center pr-2">
|
||||||
|
<slot name="label" :option="selectedOption">
|
||||||
|
<span class="text-tw-gray-700">{{ selectedOption[textAttribute] }}</span>
|
||||||
|
</slot>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="inline-flex w-full text-tw-gray-400">
|
||||||
|
{{ placeholder || '' }}
|
||||||
|
</div>
|
||||||
|
</TwButton>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
enter-class=""
|
||||||
|
enter-active-class=""
|
||||||
|
enter-to-class=""
|
||||||
|
leave-class="opacity-100"
|
||||||
|
leave-active-class="transition duration-100 ease-in"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div v-show="open" ref="tooltip" class="z-10 overflow-hidden bg-white rounded-md shadow-lg dark:bg-gray-800 ring-1 ring-gray-200 dark:ring-gray-700" :class="dropdownClass">
|
||||||
|
<div v-if="searchable" class="w-full border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<TwInput
|
||||||
|
ref="search"
|
||||||
|
v-model="q"
|
||||||
|
type="search"
|
||||||
|
:name="`select-search-${name}`"
|
||||||
|
block
|
||||||
|
autocomplete="off"
|
||||||
|
appearance="none"
|
||||||
|
:placeholder="placeholderSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
ref="options"
|
||||||
|
tabindex="-1"
|
||||||
|
role="listbox"
|
||||||
|
class="overflow-y-auto max-h-60 sm:text-sm focus:outline-none"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-if="showNewOption"
|
||||||
|
ref="option-new"
|
||||||
|
role="option"
|
||||||
|
class="relative pl-3 pr-12 cursor-default select-none group hover:text-white hover:bg-primary-600"
|
||||||
|
:class="{
|
||||||
|
'bg-primary-600 text-white': active === -1,
|
||||||
|
'text-tw-gray-900': active !== -1,
|
||||||
|
'py-2': dropdownSize === 'md',
|
||||||
|
'py-1 text-sm': dropdownSize === 'sm'
|
||||||
|
}"
|
||||||
|
@mouseover="active = -1"
|
||||||
|
@click="active === -1 && newOption()"
|
||||||
|
>
|
||||||
|
<slot name="newOption" :optionName="q">
|
||||||
|
<span class="block truncate">Add new option: "{{ q }}"</span>
|
||||||
|
</slot>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-for="(option, index) in filteredNormalizedOptions"
|
||||||
|
:key="index"
|
||||||
|
:ref="`option-${index}`"
|
||||||
|
role="option"
|
||||||
|
class="relative pl-3 pr-12 cursor-default select-none group hover:text-white hover:bg-primary-600"
|
||||||
|
:class="{
|
||||||
|
'font-semibold': isOptionSelected(option),
|
||||||
|
'bg-primary-600 text-white': active === index,
|
||||||
|
'text-tw-gray-900': active !== index,
|
||||||
|
'py-2': dropdownSize === 'md',
|
||||||
|
'py-1 text-sm': dropdownSize === 'sm'
|
||||||
|
}"
|
||||||
|
@mouseover="active = index"
|
||||||
|
@click.prevent="active === index && selectOption(option)"
|
||||||
|
>
|
||||||
|
<slot name="option" :option="option">
|
||||||
|
<span class="block truncate">{{ option[textAttribute] }}</span>
|
||||||
|
</slot>
|
||||||
|
<span class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
|
<Icon
|
||||||
|
v-if="isOptionSelected(option)"
|
||||||
|
name="solid/check"
|
||||||
|
class=" group-hover:text-white"
|
||||||
|
:class="{
|
||||||
|
'text-white': active === index,
|
||||||
|
'text-primary-600': active !== index,
|
||||||
|
'h-5 w-5': dropdownSize === 'md',
|
||||||
|
'h-4 w-4': dropdownSize === 'sm'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import get from 'lodash/get'
|
||||||
|
import { createPopper } from '@popperjs/core'
|
||||||
|
import { directive as onClickaway } from 'vue-clickaway'
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
directives: {
|
||||||
|
onClickaway
|
||||||
|
},
|
||||||
|
shortcuts: {
|
||||||
|
disabled () {
|
||||||
|
return !this.open
|
||||||
|
},
|
||||||
|
up: 'prev',
|
||||||
|
down: 'next',
|
||||||
|
enter: 'enter',
|
||||||
|
esc: {
|
||||||
|
handler: 'close',
|
||||||
|
stop: true,
|
||||||
|
prevent: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: [String, Number, Object, Array],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
textAttribute: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
valueAttribute: {
|
||||||
|
type: String,
|
||||||
|
default: 'value'
|
||||||
|
},
|
||||||
|
searchAttributes: {
|
||||||
|
type: Array,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
placeholderSearch: {
|
||||||
|
type: String,
|
||||||
|
default: 'Search...'
|
||||||
|
},
|
||||||
|
searchable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
newEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['xxs', 'xs', 'sm', 'md', 'lg', 'xl'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dropdownClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'w-full'
|
||||||
|
},
|
||||||
|
dropdownSize: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['sm', 'md'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'gray'
|
||||||
|
},
|
||||||
|
strategy: {
|
||||||
|
type: String,
|
||||||
|
default: 'absolute'
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String,
|
||||||
|
default: 'bottom-start'
|
||||||
|
},
|
||||||
|
unselectable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
open: false,
|
||||||
|
active: 0,
|
||||||
|
q: '',
|
||||||
|
instance: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
showNewOption () {
|
||||||
|
return this.newEnabled && this.q && !this.filteredNormalizedOptions.find(option => option[this.textAttribute].toLowerCase() === this.q.toLowerCase())
|
||||||
|
},
|
||||||
|
selectedOptions () {
|
||||||
|
if (this.multiple) {
|
||||||
|
return this.value.map(value => this.normalizedOptions.find(option => option[this.valueAttribute] === value)).filter(Boolean)
|
||||||
|
} else {
|
||||||
|
return [this.normalizedOptions.find(option => option[this.valueAttribute] === this.value)].filter(Boolean)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
normalizedOptions () {
|
||||||
|
return this.options.map(option => this.normalizeOption(option))
|
||||||
|
},
|
||||||
|
filteredNormalizedOptions () {
|
||||||
|
let filteredNormalizedOptions = this.normalizedOptions
|
||||||
|
|
||||||
|
if (!this.q) {
|
||||||
|
return filteredNormalizedOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
filteredNormalizedOptions = this.normalizedOptions.filter((option) => {
|
||||||
|
return (this.searchAttributes?.length ? this.searchAttributes : [this.textAttribute]).some((searchAttribute) => {
|
||||||
|
return option[searchAttribute] && option[searchAttribute].search(new RegExp(this.q, 'i')) !== -1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return filteredNormalizedOptions
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
disabled (value) {
|
||||||
|
if (value && open) { this.close() }
|
||||||
|
},
|
||||||
|
open (value) {
|
||||||
|
this.$emit('open', value)
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.searchable) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.search.$refs.input.focus()
|
||||||
|
this.$refs.search.$refs.input.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.multiple) {
|
||||||
|
if (this.value.length) {
|
||||||
|
this.active = this.filteredNormalizedOptions.findIndex(option => this.value.includes(option[this.valueAttribute]))
|
||||||
|
}
|
||||||
|
} else if (this.value) {
|
||||||
|
this.active = this.filteredNormalizedOptions.findIndex(option => option[this.valueAttribute] === this.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.destroy()
|
||||||
|
this.instance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.instance = createPopper(this.$refs.container, this.$refs.tooltip, {
|
||||||
|
strategy: this.strategy,
|
||||||
|
placement: this.placement,
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: 'offset',
|
||||||
|
options: {
|
||||||
|
offset: [0, 8]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'computeStyles',
|
||||||
|
options: {
|
||||||
|
gpuAcceleration: false,
|
||||||
|
adaptive: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preventOverflow',
|
||||||
|
options: {
|
||||||
|
padding: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollIntoView()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
filteredNormalizedOptions () {
|
||||||
|
this.updateActive()
|
||||||
|
},
|
||||||
|
q () {
|
||||||
|
this.updateActive()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.destroy()
|
||||||
|
this.instance = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggle () {
|
||||||
|
this.open = !this.open
|
||||||
|
},
|
||||||
|
close () {
|
||||||
|
this.open = false
|
||||||
|
},
|
||||||
|
newOption () {
|
||||||
|
this.$emit('new', this.q)
|
||||||
|
},
|
||||||
|
isOptionSelected (option) {
|
||||||
|
if (this.multiple) {
|
||||||
|
return this.value && this.value.find(it => it === option[this.valueAttribute])
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.value && this.value === option[this.valueAttribute]
|
||||||
|
},
|
||||||
|
selectOption (option) {
|
||||||
|
if (this.multiple) {
|
||||||
|
const value = [...this.value]
|
||||||
|
const index = value.findIndex(it => it === option[this.valueAttribute])
|
||||||
|
if (index > -1) {
|
||||||
|
value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
value.push(option[this.valueAttribute])
|
||||||
|
}
|
||||||
|
this.$emit('input', value)
|
||||||
|
} else {
|
||||||
|
if (this.isOptionSelected(option)) {
|
||||||
|
if (this.unselectable) {
|
||||||
|
this.$emit('input', null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.$emit('input', option[this.valueAttribute])
|
||||||
|
}
|
||||||
|
this.open = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
guessOptionValue (option) {
|
||||||
|
return get(option, this.valueAttribute, get(option, this.textAttribute))
|
||||||
|
},
|
||||||
|
guessOptionText (option) {
|
||||||
|
return get(option, this.textAttribute, get(option, this.valueAttribute))
|
||||||
|
},
|
||||||
|
normalizeOption (option) {
|
||||||
|
if (['string', 'number', 'boolean'].includes(typeof option)) {
|
||||||
|
return {
|
||||||
|
[this.valueAttribute]: option,
|
||||||
|
[this.textAttribute]: option
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
[this.valueAttribute]: this.guessOptionValue(option),
|
||||||
|
[this.textAttribute]: this.guessOptionText(option)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
prev () {
|
||||||
|
if (this.active - 1 >= (this.showNewOption ? -1 : 0)) {
|
||||||
|
this.active--
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollIntoView()
|
||||||
|
},
|
||||||
|
next () {
|
||||||
|
if (this.active + 1 <= (this.filteredNormalizedOptions.length - 1)) {
|
||||||
|
this.active++
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollIntoView()
|
||||||
|
},
|
||||||
|
enter () {
|
||||||
|
if (this.active === -1) {
|
||||||
|
if (this.showNewOption) {
|
||||||
|
this.newOption()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = this.filteredNormalizedOptions[this.active]
|
||||||
|
if (!option) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectOption(option)
|
||||||
|
},
|
||||||
|
scrollIntoView () {
|
||||||
|
let child
|
||||||
|
if (this.active === -1) {
|
||||||
|
child = this.$refs['option-new']
|
||||||
|
} else {
|
||||||
|
child = this.$refs[`option-${this.active}`][0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!child) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
child.scrollIntoView({ block: 'nearest' })
|
||||||
|
},
|
||||||
|
updateActive () {
|
||||||
|
this.active = this.showNewOption && !this.filteredNormalizedOptions.length ? -1 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
158
src/components/forms/Textarea.vue
Normal file
158
src/components/forms/Textarea.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="wrapperClass">
|
||||||
|
<textarea
|
||||||
|
:id="name"
|
||||||
|
ref="textarea"
|
||||||
|
v-focus="autofocus"
|
||||||
|
:name="name"
|
||||||
|
:value="value"
|
||||||
|
:rows="rows"
|
||||||
|
:required="required"
|
||||||
|
:disabled="disabled"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:class="textareaClass"
|
||||||
|
@input="updateValue($event.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Focus from '../../directives/focus'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
directives: {
|
||||||
|
focus: Focus
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Number,
|
||||||
|
default: 3
|
||||||
|
},
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
autocomplete: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
appearance: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
validator (value) {
|
||||||
|
return ['default', 'none'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
validator (value) {
|
||||||
|
return ['', 'xxs', 'xs', 'sm', 'md', 'lg', 'xl'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
baseClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'block w-full bg-tw-white disabled:cursor-not-allowed disabled:bg-tw-gray-50'
|
||||||
|
},
|
||||||
|
customClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
noResize: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sizeClass () {
|
||||||
|
return ({
|
||||||
|
xxs: 'text-xs',
|
||||||
|
xs: 'text-xs',
|
||||||
|
sm: 'text-sm leading-4',
|
||||||
|
md: 'text-sm',
|
||||||
|
lg: 'text-base',
|
||||||
|
xl: 'text-base'
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
paddingClass () {
|
||||||
|
return ({
|
||||||
|
xxs: 'px-1 py-0.5',
|
||||||
|
xs: 'px-2.5 py-1.5',
|
||||||
|
sm: 'px-3 py-2',
|
||||||
|
md: 'px-4 py-2',
|
||||||
|
lg: 'px-4 py-2',
|
||||||
|
xl: 'px-6 py-3'
|
||||||
|
})[this.size]
|
||||||
|
},
|
||||||
|
appearanceClass () {
|
||||||
|
return ({
|
||||||
|
default: 'focus:ring-primary-500 focus:border-primary-500 border-tw-gray-300 rounded-md',
|
||||||
|
none: 'border-0 bg-transparent focus:ring-0 focus:shadow-none'
|
||||||
|
})[this.appearance]
|
||||||
|
},
|
||||||
|
resizeClass () {
|
||||||
|
return ({
|
||||||
|
true: 'resize-none',
|
||||||
|
false: ''
|
||||||
|
})[this.noResize]
|
||||||
|
},
|
||||||
|
textareaClass () {
|
||||||
|
return [
|
||||||
|
this.baseClass,
|
||||||
|
this.customClass,
|
||||||
|
this.sizeClass,
|
||||||
|
this.paddingClass,
|
||||||
|
this.resizeClass,
|
||||||
|
this.appearanceClass
|
||||||
|
].join(' ')
|
||||||
|
},
|
||||||
|
wrapperClass () {
|
||||||
|
return [
|
||||||
|
'relative',
|
||||||
|
this.appearance !== 'none' ? 'shadow-sm' : ''
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value (newValue) {
|
||||||
|
this.resizeTextarea()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
this.resizeTextarea()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
resizeTextarea () {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const textarea = this.$refs.textarea
|
||||||
|
textarea.style.height = 'auto'
|
||||||
|
textarea.style.height = `${textarea.scrollHeight}px`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateValue (value) {
|
||||||
|
this.$emit('input', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
90
src/components/layout/Card.vue
Normal file
90
src/components/layout/Card.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="$listeners && $listeners.submit ? 'form': 'div'"
|
||||||
|
:class="[padded && rounded && 'rounded-md', !padded && rounded && 'sm:rounded-md', wrapperClass, ringClass, backgroundClass]"
|
||||||
|
@submit.prevent="$listeners && $listeners.submit ? $listeners.submit() : null"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="$slots.header"
|
||||||
|
:class="[headerClass, headerBackgroundClass, borderColorClass, !!$slots.default && 'border-b']"
|
||||||
|
>
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
<div :class="[bodyClass, bodyBackgroundClass]">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="$slots.footer"
|
||||||
|
:class="[footerClass, footerBackgroundClass, borderColorClass, (!!$slots.default || (!$slots.default && !!$slots.header)) && 'border-t']"
|
||||||
|
>
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
padded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
wrapperClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'overflow-hidden'
|
||||||
|
},
|
||||||
|
ringClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'ring-1 ring-gray-200 dark:ring-gray-700'
|
||||||
|
},
|
||||||
|
bodyClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'px-4 py-5 sm:p-6'
|
||||||
|
},
|
||||||
|
bodyBackgroundClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
headerClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'px-4 py-5 sm:px-6'
|
||||||
|
},
|
||||||
|
headerBackgroundClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
footerClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'px-4 py-4 sm:px-6'
|
||||||
|
},
|
||||||
|
footerBackgroundClass: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
borderColorClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'border-gray-200 dark:border-gray-700'
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'gray',
|
||||||
|
validator (value) {
|
||||||
|
return ['gray', 'white', 'black'].includes(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
backgroundClass () {
|
||||||
|
return ({
|
||||||
|
white: 'bg-white dark:bg-gray-800',
|
||||||
|
gray: 'bg-tw-gray-50',
|
||||||
|
black: 'bg-white dark:bg-black'
|
||||||
|
})[this.variant]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
20
src/components/layout/Container.vue
Normal file
20
src/components/layout/Container.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mx-auto sm:px-6 lg:px-8" :class="{ 'px-4': padded, 'max-w-7xl': constrained }">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
padded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
constrained: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
59
src/components/navigation/Pills.vue
Normal file
59
src/components/navigation/Pills.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="flex items-center space-x-1.5">
|
||||||
|
<div v-for="(link, index) of links" :key="index">
|
||||||
|
<TwButton
|
||||||
|
:size="size"
|
||||||
|
:to="link.to"
|
||||||
|
:label="link.label"
|
||||||
|
:icon="link.icon"
|
||||||
|
:variant="isActive(link) ? activeVariant : variant"
|
||||||
|
:custom-class="isActive(link) ? activeClass : ''"
|
||||||
|
@click.native="click(link)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
links: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md'
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'gray-hover'
|
||||||
|
},
|
||||||
|
activeVariant: {
|
||||||
|
type: String,
|
||||||
|
default: 'gray'
|
||||||
|
},
|
||||||
|
activeClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'text-tw-gray-700 hover:text-tw-gray-700 focus:text-tw-gray-700'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
options () {
|
||||||
|
return this.links.map(link => ({ value: link.to, text: link.label }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
click (link) {
|
||||||
|
this.$emit('input', link)
|
||||||
|
},
|
||||||
|
isActive (link) {
|
||||||
|
if (link.exact === false) {
|
||||||
|
return !!this.$route.path.startsWith(link.to)
|
||||||
|
} else {
|
||||||
|
return this.$route.path === link.to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
34
src/components/navigation/Tabs.vue
Normal file
34
src/components/navigation/Tabs.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="flex items-center gap-6">
|
||||||
|
<TwLink
|
||||||
|
v-for="(link, index) of links"
|
||||||
|
:key="index"
|
||||||
|
:to="link.to"
|
||||||
|
:exact="link.exact"
|
||||||
|
class="pt-2 pb-3 text-sm font-medium border-b-2 whitespace-nowrap"
|
||||||
|
:active-class="activeClass"
|
||||||
|
:inactive-class="inactiveClass"
|
||||||
|
>
|
||||||
|
{{ link.label }}
|
||||||
|
</TwLink>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
links: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
activeClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'border-black dark:border-white text-black dark:text-white hover:text-black dark:hover:text-white hover:border-black dark:hover:border-white'
|
||||||
|
},
|
||||||
|
inactiveClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'border-transparent text-tw-gray-500 hover:text-tw-gray-700 hover:border-tw-gray-300'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
117
src/components/navigation/VerticalNavigation.vue
Normal file
117
src/components/navigation/VerticalNavigation.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<nav>
|
||||||
|
<h3
|
||||||
|
v-if="title || $slots.title"
|
||||||
|
class="flex items-center justify-between px-2 mb-1 text-xs font-semibold tracking-wider uppercase text-tw-gray-500"
|
||||||
|
>
|
||||||
|
<slot name="title">
|
||||||
|
{{ title }}
|
||||||
|
</slot>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<TwLink
|
||||||
|
v-for="link of links"
|
||||||
|
v-slot="{ isActive }"
|
||||||
|
:key="link.to || link.label"
|
||||||
|
:to="link.to"
|
||||||
|
:exact="link.exact"
|
||||||
|
class="flex items-center px-2 py-2 text-sm font-medium rounded-md group focus:outline-none"
|
||||||
|
:active-class="activeVariantClass"
|
||||||
|
:inactive-class="variantClass"
|
||||||
|
@click.native="link.click && link.click()"
|
||||||
|
@keyup.enter.native="$event.target.blur()"
|
||||||
|
>
|
||||||
|
<slot name="icon" :link="link">
|
||||||
|
<Icon
|
||||||
|
v-if="link.icon"
|
||||||
|
:name="link.icon"
|
||||||
|
class="w-6 h-6 mr-3"
|
||||||
|
:class="isActive ? activeIconClass : iconClass"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
<slot :link="link">
|
||||||
|
<span class="truncate">{{ link.label }}</span>
|
||||||
|
</slot>
|
||||||
|
<div v-if="link.shortcuts" class="hidden ml-3 space-x-1 lg:flex">
|
||||||
|
<span v-for="shortcut of link.shortcuts" :key="shortcut" class="flex items-center justify-center w-4 h-4 font-normal bg-gray-200 rounded text-xxs dark:bg-gray-700 text-tw-gray-600">
|
||||||
|
{{ shortcut }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="inline-block ml-auto">
|
||||||
|
<slot name="badge" :link="link">
|
||||||
|
<span
|
||||||
|
v-if="link.badge"
|
||||||
|
class="ml-auto inline-block py-0.5 px-3 text-xs rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-tw-gray-50': isActive,
|
||||||
|
'bg-gray-200 dark:bg-gray-700 text-tw-gray-600': !isActive
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ link.badge }}
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
</span>
|
||||||
|
</TwLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
props: {
|
||||||
|
links: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'white',
|
||||||
|
validator (value) {
|
||||||
|
return ['white', 'gray'].includes(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
options () {
|
||||||
|
return this.links.map(link => ({ value: link.to, text: link.label }))
|
||||||
|
},
|
||||||
|
variantClass () {
|
||||||
|
return ({
|
||||||
|
white: 'text-tw-gray-600 hover:text-tw-gray-900 hover:bg-tw-gray-50 focus:bg-tw-gray-50',
|
||||||
|
gray: 'text-tw-gray-600 hover:text-tw-gray-900 hover:bg-tw-gray-50 focus:bg-tw-gray-50'
|
||||||
|
})[this.variant]
|
||||||
|
},
|
||||||
|
activeVariantClass () {
|
||||||
|
return ({
|
||||||
|
white: 'text-tw-gray-900 bg-tw-gray-100 hover:text-tw-gray-900 hover:bg-tw-gray-100 focus:bg-tw-gray-100',
|
||||||
|
gray: 'text-tw-gray-900 bg-gray-200 dark:bg-gray-800 hover:text-tw-gray-900 hover:bg-gray-200 dark:hover:bg-gray-800 focus:bg-gray-200 dark:focus:bg-gray-800'
|
||||||
|
})[this.variant]
|
||||||
|
},
|
||||||
|
iconClass () {
|
||||||
|
return ({
|
||||||
|
white: 'text-tw-gray-400 group-hover:text-tw-gray-500',
|
||||||
|
gray: 'text-tw-gray-400 group-hover:text-tw-gray-500'
|
||||||
|
})[this.variant]
|
||||||
|
},
|
||||||
|
activeIconClass () {
|
||||||
|
return ({
|
||||||
|
white: 'text-tw-gray-500 group-hover:text-tw-gray-500',
|
||||||
|
gray: 'text-tw-gray-500 group-hover:text-tw-gray-500'
|
||||||
|
})[this.variant]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
changeRoute (to) {
|
||||||
|
this.$router.push(to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
176
src/components/overlays/Modal.vue
Normal file
176
src/components/overlays/Modal.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="showModal" class="fixed inset-0 z-50 overflow-hidden modal">
|
||||||
|
<div class="flex items-start justify-center min-h-screen">
|
||||||
|
<transition
|
||||||
|
appear
|
||||||
|
enter-class="opacity-0"
|
||||||
|
enter-active-class="duration-300 ease-out"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-class="opacity-100"
|
||||||
|
leave-active-class="duration-200 ease-in"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
@before-leave="backdropLeaving = true"
|
||||||
|
@after-leave="backdropLeaving = false"
|
||||||
|
>
|
||||||
|
<div v-if="showBackdrop" class="fixed inset-0 transition-opacity bg-gray-800 sm:bg-opacity-75" @click="open = false" />
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
appear
|
||||||
|
enter-class="translate-y-4 opacity-0 sm:translate-y-0 sm:scale-95"
|
||||||
|
enter-active-class="duration-300 ease-out"
|
||||||
|
enter-to-class="translate-y-0 opacity-100 sm:scale-100"
|
||||||
|
leave-class="translate-y-0 opacity-100 sm:scale-100"
|
||||||
|
leave-active-class="duration-200 ease-in"
|
||||||
|
leave-to-class="translate-y-4 opacity-0 sm:translate-y-0 sm:scale-95"
|
||||||
|
@before-leave="contentLeaving = true"
|
||||||
|
@after-leave="contentLeaving = false"
|
||||||
|
>
|
||||||
|
<TwCard
|
||||||
|
v-if="showContent"
|
||||||
|
v-bind="$attrs"
|
||||||
|
class="z-50 flex flex-col flex-1 w-screen h-screen mx-auto overflow-hidden shadow-xl"
|
||||||
|
:class="modalClass"
|
||||||
|
variant="white"
|
||||||
|
ring-class="sm:ring-1 ring-transparent dark:ring-gray-700"
|
||||||
|
aria-modal="true"
|
||||||
|
v-on="$listeners"
|
||||||
|
>
|
||||||
|
<template v-if="$slots.header" #header>
|
||||||
|
<slot name="header" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="title" #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="font-medium sm:text-lg sm:leading-6 text-tw-gray-900">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close panel"
|
||||||
|
class="rounded-md text-tw-gray-400 hover:text-tw-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
<Icon name="outline/x" class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<slot />
|
||||||
|
<template v-if="$slots.footer" #footer>
|
||||||
|
<slot name="footer" />
|
||||||
|
</template>
|
||||||
|
</TwCard>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
fullscreen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
baseClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'sm:my-20 sm:max-w-xl sm:h-auto'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
showModal: false,
|
||||||
|
showBackdrop: false,
|
||||||
|
showContent: false,
|
||||||
|
backdropLeaving: false,
|
||||||
|
contentLeaving: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
head () {
|
||||||
|
if (this.open) {
|
||||||
|
return {
|
||||||
|
bodyAttrs: {
|
||||||
|
class: ['overflow-hidden']
|
||||||
|
},
|
||||||
|
htmlAttrs: {
|
||||||
|
style: ['touch-action: none;']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
modalClass () {
|
||||||
|
return this.fullscreen ? 'sm:m-10 sm:h-[calc(100vh-5rem)]' : this.baseClass
|
||||||
|
},
|
||||||
|
leaving () {
|
||||||
|
return this.backdropLeaving || this.contentLeaving
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
get () {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set (open) {
|
||||||
|
this.$emit('input', open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
open: {
|
||||||
|
handler (newValue) {
|
||||||
|
if (newValue) {
|
||||||
|
this.show()
|
||||||
|
} else {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediate: true
|
||||||
|
},
|
||||||
|
leaving (newValue) {
|
||||||
|
if (newValue === false) {
|
||||||
|
this.showModal = false
|
||||||
|
this.open = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shortcuts: {
|
||||||
|
disabled () {
|
||||||
|
return !this.open
|
||||||
|
},
|
||||||
|
esc: 'esc'
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
show () {
|
||||||
|
this.showModal = true
|
||||||
|
this.showBackdrop = true
|
||||||
|
this.showContent = true
|
||||||
|
},
|
||||||
|
close () {
|
||||||
|
this.showBackdrop = false
|
||||||
|
this.showContent = false
|
||||||
|
},
|
||||||
|
esc () {
|
||||||
|
this.$listeners.close ? this.$listeners.close() : this.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal {
|
||||||
|
padding: env(safe-area-inset-top) 0 0 env(safe-area-inset-left);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
187
src/components/overlays/Notification.vue
Normal file
187
src/components/overlays/Notification.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<transition
|
||||||
|
appear
|
||||||
|
enter-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||||
|
enter-active-class="transition duration-300 ease-out transform"
|
||||||
|
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||||
|
leave-class="opacity-100"
|
||||||
|
leave-active-class="transition duration-100 ease-in"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="z-50 w-full bg-white rounded-lg shadow-lg pointer-events-auto dark:bg-gray-800"
|
||||||
|
@mouseover="onMouseover"
|
||||||
|
@mouseout="onMouseout"
|
||||||
|
>
|
||||||
|
<div class="relative overflow-hidden rounded-lg ring-1 ring-gray-200 dark:ring-gray-700">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<Icon :name="iconName" class="w-6 h-6" :class="iconClass" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||||
|
<p class="text-sm font-medium leading-5 text-tw-gray-900">
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
<p v-if="description" class="mt-1 text-sm leading-5 text-tw-gray-500">
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
<TwButton
|
||||||
|
v-if="undo"
|
||||||
|
variant="white"
|
||||||
|
size="xs"
|
||||||
|
class="mt-2"
|
||||||
|
@click.native.stop="cancel"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
<div class="inline-flex items-center rounded bg-tw-gray-200 ml-1.5">
|
||||||
|
<span class="w-full px-1 text-center text-tw-gray-600 text-xxs">
|
||||||
|
Z
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TwButton>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0 ml-4">
|
||||||
|
<button
|
||||||
|
class="transition duration-150 ease-in-out text-tw-gray-400 focus:outline-none hover:text-tw-gray-500 focus:text-tw-gray-500"
|
||||||
|
@click.stop="close"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<Icon name="outline/x" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="timeout" class="absolute bottom-0 left-0 right-0 h-1">
|
||||||
|
<div class="h-1 bg-primary-500" :style="progressBarStyle" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
props: {
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
default: 'info',
|
||||||
|
validator (value) {
|
||||||
|
return ['info', 'success', 'error', 'warning'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
type: Number,
|
||||||
|
default: 5000
|
||||||
|
},
|
||||||
|
undo: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
callback: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
timer: null,
|
||||||
|
ticker: null,
|
||||||
|
remainingTime: this.timeout
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
iconName () {
|
||||||
|
return this.icon || ({
|
||||||
|
warning: 'solid/exclamation-circle',
|
||||||
|
info: 'solid/information-circle',
|
||||||
|
success: 'solid/check-circle',
|
||||||
|
error: 'solid/x-circle'
|
||||||
|
})[this.type]
|
||||||
|
},
|
||||||
|
iconClass () {
|
||||||
|
return ({
|
||||||
|
warning: 'text-orange-400',
|
||||||
|
info: 'text-blue-400',
|
||||||
|
success: 'text-green-400',
|
||||||
|
error: 'text-red-400'
|
||||||
|
})[this.type] || 'text-tw-gray-400'
|
||||||
|
},
|
||||||
|
progressBarStyle () {
|
||||||
|
const remainingPercent = this.remainingTime / this.timeout * 100
|
||||||
|
return { width: `${remainingPercent}%` }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
if (!this.$timer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.timeout) {
|
||||||
|
this.timer = new this.$timer(() => {
|
||||||
|
this.close()
|
||||||
|
this.ticker?.stop()
|
||||||
|
}, this.timeout)
|
||||||
|
this.ticker = new this.$ticker(() => {
|
||||||
|
this.remainingTime -= 10
|
||||||
|
}, 10)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
if (this.timer) {
|
||||||
|
this.timer.stop()
|
||||||
|
this.ticker.stop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onMouseover () {
|
||||||
|
if (this.timer) {
|
||||||
|
this.timer.pause()
|
||||||
|
this.ticker.stop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMouseout () {
|
||||||
|
if (this.timer) {
|
||||||
|
this.timer.resume()
|
||||||
|
this.ticker.start()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel () {
|
||||||
|
if (this.timer) {
|
||||||
|
this.timer.stop()
|
||||||
|
this.ticker.stop()
|
||||||
|
}
|
||||||
|
if (this.undo) {
|
||||||
|
this.undo()
|
||||||
|
}
|
||||||
|
this.$emit('close')
|
||||||
|
},
|
||||||
|
close () {
|
||||||
|
if (this.callback) {
|
||||||
|
this.callback()
|
||||||
|
}
|
||||||
|
this.$emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
213
src/components/overlays/Popover.vue
Normal file
213
src/components/overlays/Popover.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="container"
|
||||||
|
v-on-clickaway="onClickaway"
|
||||||
|
class="inline whitespace-normal"
|
||||||
|
@mouseover="mode === 'hover' ? mouseover() : () => {}"
|
||||||
|
@mouseleave="mode === 'hover' ? mouseleave() : () => {}"
|
||||||
|
>
|
||||||
|
<slot :toggle="toggle" />
|
||||||
|
|
||||||
|
<Portal to="popover">
|
||||||
|
<transition
|
||||||
|
enter-class="transform scale-95 opacity-0"
|
||||||
|
enter-active-class="transition duration-100 ease-out"
|
||||||
|
enter-to-class="transform scale-100 opacity-100"
|
||||||
|
leave-class="opacity-100"
|
||||||
|
leave-active-class="duration-100 ease-in"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="show && ready && $scopedSlots.content"
|
||||||
|
ref="popover"
|
||||||
|
class="z-30 flex bg-white rounded-md shadow ring-1 ring-gray-200 dark:ring-gray-700"
|
||||||
|
:class="[
|
||||||
|
popoverClass,
|
||||||
|
darken ? 'dark:bg-gray-900' : 'dark:bg-gray-800',
|
||||||
|
mode === 'click' ? '' : 'invisible lg:visible'
|
||||||
|
]"
|
||||||
|
@mouseover="mode === 'hover' ? mouseover() : () => {}"
|
||||||
|
@mouseleave="mode === 'hover' ? mouseleave() : () => {}"
|
||||||
|
>
|
||||||
|
<slot name="content" :toggle="toggle" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Portal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { createPopper } from '@popperjs/core'
|
||||||
|
import { directive as onClickaway } from 'vue-clickaway'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
directives: {
|
||||||
|
onClickaway
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
darken: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
popoverClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'w-auto h-auto'
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'hover',
|
||||||
|
validator (value) {
|
||||||
|
return ['click', 'hover'].includes(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
strategy: {
|
||||||
|
type: String,
|
||||||
|
default: 'fixed'
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String,
|
||||||
|
default: 'bottom'
|
||||||
|
},
|
||||||
|
openDelay: {
|
||||||
|
type: Number,
|
||||||
|
default: 300
|
||||||
|
},
|
||||||
|
closeDelay: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
show: false,
|
||||||
|
instance: null,
|
||||||
|
openTimeout: null,
|
||||||
|
closeTimeout: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show (value) {
|
||||||
|
this.$emit('show', value)
|
||||||
|
|
||||||
|
if (!value || !this.ready) {
|
||||||
|
// let the 100ms hide animation proceed before destroying the popper instance
|
||||||
|
setTimeout(() => {
|
||||||
|
// if popover reshow, abort destroy
|
||||||
|
if (this.show && this.ready) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.destroyPopper()
|
||||||
|
}, 120)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.createPopper()
|
||||||
|
},
|
||||||
|
ready (value) {
|
||||||
|
if (!value || !this.show) {
|
||||||
|
// let the 100ms hide animation proceed before destroying the popper instance
|
||||||
|
setTimeout(() => {
|
||||||
|
// if popover reshow, abort destroy
|
||||||
|
if (this.show && this.ready) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.destroyPopper()
|
||||||
|
}, 120)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.createPopper()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
this.destroyPopper()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
createPopper () {
|
||||||
|
// https://portal-vue.linusb.org/guide/caveats.html#refs
|
||||||
|
this.$nextTick().then(
|
||||||
|
this.$nextTick().then(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.forceUpdate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.instance = createPopper(this.$refs.container, this.$refs.popover, {
|
||||||
|
strategy: this.strategy,
|
||||||
|
placement: this.placement,
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: 'offset',
|
||||||
|
options: {
|
||||||
|
offset: [0, 8]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'computeStyles',
|
||||||
|
options: {
|
||||||
|
gpuAcceleration: false,
|
||||||
|
adaptive: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preventOverflow',
|
||||||
|
options: {
|
||||||
|
padding: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}, 1)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
destroyPopper () {
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.destroy()
|
||||||
|
this.instance = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseover () {
|
||||||
|
clearTimeout(this.closeTimeout)
|
||||||
|
this.closeTimeout = null
|
||||||
|
if (this.show) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.openTimeout = this.openTimeout || setTimeout(() => {
|
||||||
|
this.open()
|
||||||
|
this.openTimeout = null
|
||||||
|
}, this.openDelay)
|
||||||
|
},
|
||||||
|
mouseleave () {
|
||||||
|
clearTimeout(this.openTimeout)
|
||||||
|
this.openTimeout = null
|
||||||
|
if (!this.show) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.closeTimeout = this.closeTimeout || setTimeout(() => {
|
||||||
|
this.close()
|
||||||
|
this.closeTimeout = null
|
||||||
|
}, this.closeDelay)
|
||||||
|
},
|
||||||
|
toggle () {
|
||||||
|
this.show = !this.show
|
||||||
|
},
|
||||||
|
open () {
|
||||||
|
this.show = true
|
||||||
|
},
|
||||||
|
close () {
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
|
onClickaway () {
|
||||||
|
if (this.mode !== 'hover') {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
207
src/components/overlays/Slideover.vue
Normal file
207
src/components/overlays/Slideover.vue
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="showSlideover" ref="container" class="fixed inset-0 z-50 overflow-hidden slideover">
|
||||||
|
<div class="absolute inset-0 overflow-hidden">
|
||||||
|
<transition
|
||||||
|
appear
|
||||||
|
enter-class="opacity-0"
|
||||||
|
enter-active-class="duration-300 ease-out"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-class="opacity-100"
|
||||||
|
leave-active-class="duration-200 ease-in"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
@before-leave="backdropLeaving = true"
|
||||||
|
@after-leave="backdropLeaving = false"
|
||||||
|
>
|
||||||
|
<div v-if="showBackdrop" class="fixed inset-0 transition-opacity bg-gray-800 sm:bg-opacity-75" @click="open = false" />
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<section class="absolute inset-y-0 right-0 flex max-w-full sm:pl-16">
|
||||||
|
<transition
|
||||||
|
appear
|
||||||
|
v-bind="transitionProps"
|
||||||
|
@before-leave="contentLeaving = true"
|
||||||
|
@after-leave="contentLeaving = false"
|
||||||
|
>
|
||||||
|
<TwCard
|
||||||
|
v-if="showContent"
|
||||||
|
v-bind="$attrs"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
class="z-50 flex flex-col w-screen h-screen transition transform shadow-xl sm:max-w-md "
|
||||||
|
body-class="flex-1 overflow-y-auto"
|
||||||
|
ring-class="sm:ring-1 ring-transparent dark:ring-gray-700"
|
||||||
|
variant="white"
|
||||||
|
:rounded="false"
|
||||||
|
v-on="$listeners"
|
||||||
|
>
|
||||||
|
<template v-if="$slots.header" #header>
|
||||||
|
<slot name="header" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="title" #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="font-medium sm:leading-6 sm:text-lg text-tw-gray-900">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close panel"
|
||||||
|
class="rounded-md text-tw-gray-400 hover:text-tw-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
<Icon name="outline/x" class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<slot />
|
||||||
|
<template v-if="$slots.footer" #footer>
|
||||||
|
<slot name="footer" />
|
||||||
|
</template>
|
||||||
|
</TwCard>
|
||||||
|
</transition>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import focusLock from 'dom-focus-lock'
|
||||||
|
|
||||||
|
import Icon from '../elements/icon'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { Icon },
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
showSlideover: false,
|
||||||
|
showBackdrop: false,
|
||||||
|
showContent: false,
|
||||||
|
backdropLeaving: false,
|
||||||
|
contentLeaving: false,
|
||||||
|
lock: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
head () {
|
||||||
|
if (this.open) {
|
||||||
|
return {
|
||||||
|
bodyAttrs: {
|
||||||
|
class: ['overflow-hidden']
|
||||||
|
},
|
||||||
|
htmlAttrs: {
|
||||||
|
style: ['touch-action: none;']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
transitionProps () {
|
||||||
|
// Same transition than Modal but only on mobile
|
||||||
|
if (this.$mq === 'xs') {
|
||||||
|
return {
|
||||||
|
enterClass: 'translate-y-4 opacity-0 sm:translate-y-0 sm:scale-95',
|
||||||
|
enterActiveClass: 'duration-300 ease-out',
|
||||||
|
enterToClass: 'translate-y-0 opacity-100 sm:scale-100',
|
||||||
|
leaveClass: 'translate-y-0 opacity-100 sm:scale-100',
|
||||||
|
leaveActiveClass: 'duration-200 ease-in',
|
||||||
|
leaveToClass: 'translate-y-4 opacity-0 sm:translate-y-0 sm:scale-95'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
enterClass: 'translate-x-full',
|
||||||
|
enterActiveClass: 'transition duration-500 ease-in-out transform sm:duration-700',
|
||||||
|
enterToClass: 'translate-x-0',
|
||||||
|
leaveClass: 'translate-x-0',
|
||||||
|
leaveActiveClass: 'transition duration-500 ease-in-out transform sm:duration-700',
|
||||||
|
leaveToClass: 'translate-x-full'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leaving () {
|
||||||
|
return this.backdropLeaving || this.contentLeaving
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
get () {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set (open) {
|
||||||
|
this.$emit('input', open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
open: {
|
||||||
|
handler (newValue) {
|
||||||
|
if (newValue) {
|
||||||
|
this.show()
|
||||||
|
} else {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediate: true
|
||||||
|
},
|
||||||
|
leaving (newValue) {
|
||||||
|
if (newValue === false) {
|
||||||
|
this.showSlideover = false
|
||||||
|
this.open = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
if (this.lock) {
|
||||||
|
focusLock.off(this.$refs.container)
|
||||||
|
this.lock = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shortcuts: {
|
||||||
|
disabled () {
|
||||||
|
return !this.open
|
||||||
|
},
|
||||||
|
esc: 'esc'
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
show () {
|
||||||
|
this.showSlideover = true
|
||||||
|
this.showBackdrop = true
|
||||||
|
this.showContent = true
|
||||||
|
// Remove current focus if any, avoiding the close button to autofocus and break opening animation.
|
||||||
|
document.activeElement?.blur()
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.container && !this.lock) {
|
||||||
|
focusLock.on(this.$refs.container)
|
||||||
|
this.lock = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
close () {
|
||||||
|
this.showBackdrop = false
|
||||||
|
this.showContent = false
|
||||||
|
if (this.lock) {
|
||||||
|
focusLock.off(this.$refs.container)
|
||||||
|
this.lock = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
esc () {
|
||||||
|
this.$listeners.close ? this.$listeners.close() : this.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slideover {
|
||||||
|
margin: env(safe-area-inset-top) 0 0 env(safe-area-inset-left);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
156
src/components/overlays/Tooltip.vue
Normal file
156
src/components/overlays/Tooltip.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="container" v-on-clickaway="close" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<transition
|
||||||
|
enter-class="transform scale-95 opacity-0"
|
||||||
|
enter-active-class="transition duration-100 ease-out"
|
||||||
|
enter-to-class="transform scale-100 opacity-100"
|
||||||
|
leave-class="opacity-100"
|
||||||
|
leave-active-class="duration-100 ease-in"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-show="show && (text || shortcuts.length || $slots.text)"
|
||||||
|
ref="tooltip"
|
||||||
|
class="fixed z-30 flex items-center justify-center invisible w-auto h-6 max-w-xs px-2 space-x-1 truncate rounded shadow lg:visible"
|
||||||
|
:class="{
|
||||||
|
'bg-gray-800': !darken,
|
||||||
|
'bg-gray-900': darken
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span v-if="text || $slots.text" class="truncate text-gray-50 text-xxs">
|
||||||
|
<slot name="text">{{ text }}</slot>
|
||||||
|
</span>
|
||||||
|
<div v-if="shortcuts && shortcuts.length">
|
||||||
|
<div class="flex items-center flex-shrink-0 h-6 space-x-1">
|
||||||
|
<span v-if="text" class="mb-2 text-center text-gray-500">.</span>
|
||||||
|
<div
|
||||||
|
v-for="(shortcut, index) of shortcuts"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center px-1 bg-gray-600 rounded"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-light text-center text-gray-200 text-xxs"
|
||||||
|
>{{ shortcut }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { createPopper } from '@popperjs/core'
|
||||||
|
import { directive as onClickaway } from 'vue-clickaway'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
directives: {
|
||||||
|
onClickaway
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
shortcuts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
darken: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
openDelay: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
closeDelay: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String,
|
||||||
|
default: 'bottom'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
show: false,
|
||||||
|
instance: null,
|
||||||
|
openTimeout: null,
|
||||||
|
closeTimeout: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show (value) {
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.destroy()
|
||||||
|
this.instance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.instance = createPopper(this.$refs.container, this.$refs.tooltip, {
|
||||||
|
strategy: 'fixed',
|
||||||
|
placement: this.placement,
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: 'offset',
|
||||||
|
options: {
|
||||||
|
offset: [0, 8]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'computeStyles',
|
||||||
|
options: {
|
||||||
|
gpuAcceleration: false,
|
||||||
|
adaptive: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'preventOverflow',
|
||||||
|
options: {
|
||||||
|
padding: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy () {
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.destroy()
|
||||||
|
this.instance = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
mouseover () {
|
||||||
|
clearTimeout(this.closeTimeout)
|
||||||
|
this.closeTimeout = null
|
||||||
|
this.openTimeout = this.openTimeout || setTimeout(() => {
|
||||||
|
this.open()
|
||||||
|
this.openTimeout = null
|
||||||
|
}, this.openDelay)
|
||||||
|
},
|
||||||
|
mouseleave () {
|
||||||
|
clearTimeout(this.openTimeout)
|
||||||
|
this.openTimeout = null
|
||||||
|
this.closeTimeout = this.closeTimeout || setTimeout(() => {
|
||||||
|
this.close()
|
||||||
|
this.closeTimeout = null
|
||||||
|
}, this.closeDelay)
|
||||||
|
},
|
||||||
|
open () {
|
||||||
|
this.show = true
|
||||||
|
},
|
||||||
|
close () {
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
23
src/index.ts
23
src/index.ts
@@ -75,9 +75,28 @@ export default defineNuxtModule({
|
|||||||
await installModule(nuxt, { src: '@unocss/nuxt', options })
|
await installModule(nuxt, { src: '@unocss/nuxt', options })
|
||||||
|
|
||||||
nuxt.hook('components:dirs', (dirs) => {
|
nuxt.hook('components:dirs', (dirs) => {
|
||||||
// Add ./components dir to the list
|
|
||||||
dirs.push({
|
dirs.push({
|
||||||
path: join(__dirname, 'components'),
|
path: join(__dirname, 'components/elements'),
|
||||||
|
prefix: _options.prefix || 'u'
|
||||||
|
})
|
||||||
|
dirs.push({
|
||||||
|
path: join(__dirname, 'components/feedback'),
|
||||||
|
prefix: _options.prefix || 'u'
|
||||||
|
})
|
||||||
|
dirs.push({
|
||||||
|
path: join(__dirname, 'components/forms'),
|
||||||
|
prefix: _options.prefix || 'u'
|
||||||
|
})
|
||||||
|
dirs.push({
|
||||||
|
path: join(__dirname, 'components/layout'),
|
||||||
|
prefix: _options.prefix || 'u'
|
||||||
|
})
|
||||||
|
dirs.push({
|
||||||
|
path: join(__dirname, 'components/navigation'),
|
||||||
|
prefix: _options.prefix || 'u'
|
||||||
|
})
|
||||||
|
dirs.push({
|
||||||
|
path: join(__dirname, 'components/overlays'),
|
||||||
prefix: _options.prefix || 'u'
|
prefix: _options.prefix || 'u'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user