feat: module improvements

This commit is contained in:
Benjamin Canac
2021-12-22 12:13:52 +01:00
parent a407663ad9
commit 74bd7bc180
35 changed files with 32 additions and 54 deletions

View File

@@ -0,0 +1,165 @@
<template>
<span class="relative inline-flex items-center justify-center" :class="avatarClass" @click="goto">
<img v-if="url" :src="url" :alt="alt" :class="[sizeClass, roundedClass]">
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-else-if="gradientPlaceholder" class="w-full h-full overflow-hidden" :class="roundedClass" v-html="gradientPlaceholder" />
<span
v-else-if="placeholder"
class="font-medium leading-none u-text-gray-900 uppercase"
>{{ placeholder }}</span>
<span
v-else-if="text"
>{{ text }}</span>
<svg
v-else
class="w-full h-full u-text-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 u-ring-white"
: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: 'u-bg-gray-100',
false: 'u-bg-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: 'u-bg-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>

View File

@@ -0,0 +1,62 @@
<template>
<div class="flex">
<Avatar
v-for="(avatar, index) of avatars"
:key="index"
:src="avatar.src"
class="ring-2 u-ring-white -ml-1.5 first:ml-0"
:size="size"
:status="avatar.status"
/>
<Avatar
v-if="remainingGroupSize > 0"
class="ring-2 u-ring-white -ml-1.5 first:ml-0 text-[10px]"
:size="size"
:text="`+${remainingGroupSize}`"
/>
</div>
</template>
<script>
import Avatar from './Avatar'
export default {
components: {
Avatar
},
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: {
avatars () {
return this.group.map((avatar) => {
return typeof avatar === 'string' ? { src: avatar } : avatar
})
},
displayedGroup () {
if (!this.max) { return this.avatars }
return this.avatars.slice(0, this.max)
},
remainingGroupSize () {
if (!this.max) { return 0 }
return this.avatars.length - this.max
}
}
}
</script>

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

View File

@@ -0,0 +1,185 @@
<template>
<component
:is="buttonIs"
ref="button"
:class="buttonClass"
:aria-label="ariaLabel"
v-bind="buttonProps"
>
<Icon v-if="isLeading" :name="iconName" :class="iconClass" aria-hidden="true" />
<slot><span :class="truncate ? 'text-left break-all line-clamp-1' : ''">{{ label }}</span></slot>
<Icon v-if="isTrailing" :name="iconName" :class="iconClass" aria-hidden="true" />
</component>
</template>
<script>
import { ref, computed } from 'vue'
import Link from '../elements/Link'
import Icon from '../elements/Icon'
import { classNames } from '../../utils'
import $ui from '#build/ui'
export default {
components: {
Icon,
Link
},
props: {
type: {
type: String,
default: 'button'
},
block: {
type: Boolean,
default: false
},
label: {
type: String,
default: null
},
loading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'md',
validator (value) {
return Object.keys($ui.button.size).includes(value)
}
},
variant: {
type: String,
default: 'primary',
validator (value) {
return Object.keys($ui.button.variant).includes(value)
}
},
icon: {
type: String,
default: null
},
loadingIcon: {
type: String,
default: () => $ui.button.icon.loading
},
trailing: {
type: Boolean,
default: false
},
leading: {
type: Boolean,
default: false
},
to: {
type: [String, Object],
default: null
},
target: {
type: String,
default: null
},
ariaLabel: {
type: String,
default: null
},
rounded: {
type: Boolean,
default: false
},
baseClass: {
type: String,
default: () => $ui.button.base
},
iconBaseClass: {
type: String,
default: () => $ui.button.icon.base
},
customClass: {
type: String,
default: null
},
square: {
type: Boolean,
default: false
},
truncate: {
type: Boolean,
default: false
}
},
setup (props, { slots }) {
const button = ref(null)
const buttonIs = computed(() => {
if (props.to) {
return 'Link'
}
return 'button'
})
const buttonProps = computed(() => {
switch (buttonIs.value) {
case 'Link': return { to: props.to, target: props.target }
default: return { disabled: props.disabled || props.loading, type: props.type }
}
})
const isLeading = computed(() => {
return (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing)
})
const isTrailing = computed(() => {
return (props.icon && props.trailing) || (props.loading && props.trailing)
})
const isSquare = computed(() => props.square || (!slots.default && !props.label))
const buttonClass = computed(() => {
return classNames(
props.baseClass,
$ui.button.size[props.size],
$ui.button[isSquare.value ? 'square' : 'spacing'][props.size],
$ui.button.variant[props.variant],
props.block ? 'w-full flex justify-center items-center' : 'inline-flex items-center',
props.rounded ? 'rounded-full' : 'rounded-md',
props.customClass
)
})
const iconName = computed(() => {
if (props.loading) {
return props.loadingIcon
}
return props.icon
})
const iconClass = computed(() => {
return classNames(
props.iconBaseClass,
$ui.input.icon.size[props.size],
isLeading.value && (!!slots.default || !!props.label?.length) && $ui.button.icon.leading.spacing[props.size],
isTrailing.value && (!!slots.default || !!props.label?.length) && $ui.button.icon.trailing.spacing[props.size],
props.loading && 'animate-spin'
)
})
return {
button,
buttonIs,
buttonProps,
buttonClass,
isLeading,
isTrailing,
iconName,
iconClass
}
}
}
</script>

View File

@@ -0,0 +1,161 @@
<template>
<Menu v-slot="{ open }" as="div" :class="wrapperClass">
<MenuButton ref="trigger" as="div">
<slot :open="open">
<button>Open</button>
</slot>
</MenuButton>
<div v-if="open" ref="container" :class="containerClass">
<transition
appear
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-out"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems :class="itemsClass" static>
<div v-for="(subItems, index) of items" :key="index" class="py-1">
<MenuItem v-for="(item, subIndex) of subItems" :key="subIndex" v-slot="{ active, disabled }">
<Component v-bind="item" :is="(item.to && 'Link') || 'button'" :class="resolveItemClass({ active, disabled })" @click="onItemClick(item)">
<slot :name="item.slot" :item="item">
<Icon v-if="item.icon" :name="item.icon" :class="itemIconClass" />
{{ item.label }}
</slot>
</Component>
</MenuItem>
</div>
</MenuItems>
</transition>
</div>
</Menu>
</template>
<script>
import {
Menu,
MenuButton,
MenuItems,
MenuItem
} from '@headlessui/vue'
import Icon from '../elements/Icon'
import Link from '../elements/Link'
import { classNames, usePopper } from '../../utils'
export default {
components: {
Menu,
MenuButton,
MenuItems,
MenuItem,
Icon,
Link
},
props: {
items: {
type: Array,
default: () => []
},
placement: {
type: String,
default: 'bottom-end',
validator: (value) => {
return ['auto', 'auto-start', 'auto-end', 'top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end', 'right', 'right-start', 'right-end', 'left', 'left-start', 'left-end'].includes(value)
}
},
strategy: {
type: String,
default: 'fixed',
validator: (value) => {
return ['absolute', 'fixed'].includes(value)
}
},
wrapperClass: {
type: String,
default: 'relative inline-block text-left'
},
containerClass: {
type: String,
default: 'w-48 z-20'
},
itemsClass: {
type: String,
default: 'u-bg-white divide-y u-divide-gray-100 rounded-md ring-1 ring-black ring-opacity-5'
},
itemClass: {
type: String,
default: 'group flex items-center px-4 py-2 text-sm w-full'
},
itemActiveClass: {
type: String,
default: 'u-bg-gray-100 u-text-gray-900'
},
itemInactiveClass: {
type: String,
default: 'u-text-gray-700'
},
itemDisabledClass: {
type: String,
default: 'cursor-not-allowed opacity-50'
},
itemIconClass: {
type: String,
default: 'mr-3 h-5 w-5 u-text-gray-400 group-hover:u-text-gray-500'
}
},
setup (props) {
const [trigger, container] = usePopper({
placement: props.placement,
strategy: props.strategy,
modifiers: [{
name: 'offset',
options: {
offset: [0, 8]
}
},
{
name: 'computeStyles',
options: {
gpuAcceleration: false,
adaptive: false
}
},
{
name: 'preventOverflow',
options: {
padding: 8
}
}]
})
function resolveItemClass ({ active, disabled }) {
return classNames(
props.itemClass,
active ? props.itemActiveClass : props.itemInactiveClass,
disabled && props.itemDisabledClass
)
}
function onItemClick (item) {
if (item.disabled) {
return
}
if (item.click) {
item.click()
}
}
return {
trigger,
container,
onItemClick,
resolveItemClass
}
}
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div :class="name" />
</template>
<script>
export default {
props: {
name: {
type: String,
required: true
}
}
}
</script>

View File

@@ -0,0 +1,64 @@
<template>
<a v-if="isExternalLink" v-bind="$attrs" :class="isActive ? activeClass : inactiveClass" :href="to" :target="target">
<slot v-bind="{ isActive }" />
</a>
<router-link
v-else
v-slot="{ href, navigate }"
v-bind="$props"
custom
>
<a
v-bind="$attrs"
:href="href"
:class="isActive ? activeClass : inactiveClass"
@click="navigate"
>
<slot v-bind="{ isActive }" />
</a>
</router-link>
</template>
<script>
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useRoute } from '#imports'
export default {
name: 'Link',
inheritAttrs: false,
props: {
...RouterLink.props,
inactiveClass: {
type: String,
default: ''
},
exact: {
type: Boolean,
default: false
},
target: {
type: String,
default: null
}
},
setup (props) {
const route = useRoute()
const isActive = computed(() => {
if (props.exact) {
return [props.to, `${props.to}/`].includes(route.path)
} else {
return !!route.path.startsWith(props.to)
}
})
const isExternalLink = computed(() => {
return typeof props.to === 'string' && props.to.startsWith('http')
})
return {
isActive,
isExternalLink
}
}
}
</script>

View File

@@ -0,0 +1,96 @@
<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 }} &rarr;
</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: 'heroicons-solid:information-circle',
warning: 'heroicons-solid:exclamation',
error: 'heroicons-solid:x-circle',
success: 'heroicons-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>

View File

@@ -0,0 +1,113 @@
<template>
<div :class="wrapperClass">
<div class="flex items-center h-5">
<input
:id="name"
v-model="isChecked"
:name="name"
:required="required"
:value="value"
:disabled="disabled"
type="checkbox"
:class="inputClass"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
>
</div>
<div v-if="label" class="ml-3 text-sm">
<label :for="name" :class="labelClass">
{{ label }}
<span v-if="required" :class="requiredClass">*</span>
</label>
<p v-if="help" :class="helpClass">
{{ help }}
</p>
</div>
</div>
</template>
<script>
import { computed } from 'vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
export default {
props: {
value: {
type: [String, Number, Boolean],
default: null
},
modelValue: {
type: [String, Number, Boolean, Array],
default: null
},
name: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
help: {
type: String,
default: null
},
label: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
wrapperClass: {
type: String,
default: () => $ui.checkbox.wrapper
},
baseClass: {
type: String,
default: () => $ui.checkbox.base
},
labelClass: {
type: String,
default: () => $ui.checkbox.label
},
requiredClass: {
type: String,
default: () => $ui.checkbox.required
},
helpClass: {
type: String,
default: () => $ui.checkbox.help
},
customClass: {
type: String,
default: null
}
},
emits: ['update:modelValue', 'focus', 'blur'],
setup (props, { emit }) {
const isChecked = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const inputClass = computed(() => {
return classNames(
props.baseClass,
props.customClass
)
})
return {
isChecked,
inputClass
}
}
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<div>
<slot name="label">
<div class="flex content-center justify-between">
<label
v-if="label"
:for="name"
:class="labelClass"
@click="onLabelClick"
>
{{ label }}
<span v-if="required" :class="requiredClass">*</span>
</label>
<span v-if="$slots.hint || hint" :class="hintClass">
<slot name="hint">{{ hint }}</slot>
</span>
</div>
<p v-if="description" :class="descriptionClass">
{{ description }}
</p>
</slot>
<div :class="!!label && containerClass">
<slot />
<p v-if="help" :class="helpClass">
{{ help }}
</p>
</div>
</div>
</template>
<script>
import $ui from '#build/ui'
export default {
props: {
name: {
type: String,
default: null
},
label: {
type: String,
default: null
},
description: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
help: {
type: String,
default: null
},
hint: {
type: String,
default: null
},
containerClass: {
type: String,
default: () => $ui.formGroup.container
},
labelClass: {
type: String,
default: () => $ui.formGroup.label
},
descriptionClass: {
type: String,
default: () => $ui.formGroup.description
},
requiredClass: {
type: String,
default: () => $ui.formGroup.required
},
hintClass: {
type: String,
default: () => $ui.formGroup.hint
},
helpClass: {
type: String,
default: () => $ui.formGroup.help
}
}
}
</script>

View File

@@ -0,0 +1,206 @@
<template>
<div :class="wrapperClass">
<div v-if="isLeading" :class="iconLeadingWrapperClass">
<Icon :name="iconName" :class="iconClass" />
</div>
<input
:id="name"
ref="input"
:name="name"
:value="modelValue"
:type="type"
:required="required"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:class="inputClass"
@input="onInput($event.target.value)"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
>
<slot />
<div v-if="isTrailing" :class="iconTrailingWrapperClass">
<Icon :name="iconName" :class="iconClass" />
</div>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import Icon from '../elements/Icon'
import { classNames } from '../../utils'
import $ui from '#build/ui'
export default {
components: {
Icon
},
props: {
modelValue: {
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: () => $ui.input.icon.loading
},
trailing: {
type: Boolean,
default: false
},
leading: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'md',
validator (value) {
return Object.keys($ui.input.size).includes(value)
}
},
wrapperClass: {
type: String,
default: () => $ui.input.wrapper
},
baseClass: {
type: String,
default: () => $ui.input.base
},
iconBaseClass: {
type: String,
default: () => $ui.input.icon.base
},
customClass: {
type: String,
default: null
},
appearance: {
type: String,
default: 'default',
validator (value) {
return Object.keys($ui.input.appearance).includes(value)
}
},
loading: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'focus', 'blur'],
setup (props, { emit }) {
const input = ref(null)
const autoFocus = () => {
if (props.autofocus) {
input.value.focus()
}
}
const onInput = (value) => {
emit('update:modelValue', value)
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, 100)
})
const isLeading = computed(() => {
return (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing)
})
const isTrailing = computed(() => {
return (props.icon && props.trailing) || (props.loading && props.trailing)
})
const inputClass = computed(() => {
return classNames(
props.baseClass,
$ui.input.size[props.size],
$ui.input.spacing[props.size],
$ui.input.appearance[props.appearance],
isLeading.value && $ui.input.leading.spacing[props.size],
isTrailing.value && $ui.input.trailing.spacing[props.size],
props.customClass
)
})
const iconName = computed(() => {
if (props.loading) {
return props.loadingIcon
}
return props.icon
})
const iconClass = computed(() => {
return classNames(
props.iconBaseClass,
$ui.input.icon.size[props.size],
isLeading.value && $ui.input.icon.leading.spacing[props.size],
isTrailing.value && $ui.input.icon.trailing.spacing[props.size],
props.loading && 'animate-spin'
)
})
const iconLeadingWrapperClass = $ui.input.icon.leading.wrapper
const iconTrailingWrapperClass = $ui.input.icon.trailing.wrapper
return {
input,
onInput,
inputClass,
iconName,
iconClass,
iconLeadingWrapperClass,
iconTrailingWrapperClass,
isLeading,
isTrailing
}
}
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div :class="wrapperClass">
<div class="flex items-center h-5">
<input
:id="`${name}-${value}`"
v-model="isChecked"
:name="name"
:required="required"
:value="value"
:disabled="disabled"
type="radio"
:class="radioClass"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
>
</div>
<div v-if="label" class="ml-3 text-sm">
<label :for="`${name}-${value}`" :class="labelClass">
{{ label }}
<span v-if="required" :class="requiredClass">*</span>
</label>
<p v-if="help" :class="helpClass">
{{ help }}
</p>
</div>
</div>
</template>
<script>
import { computed } from 'vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
export default {
props: {
value: {
type: [String, Number, Boolean],
default: null
},
modelValue: {
type: [String, Number, Boolean, Object],
default: null
},
name: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
help: {
type: String,
default: null
},
label: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
wrapperClass: {
type: String,
default: () => $ui.radio.wrapper
},
baseClass: {
type: String,
default: () => $ui.radio.base
},
labelClass: {
type: String,
default: () => $ui.radio.label
},
requiredClass: {
type: String,
default: () => $ui.radio.required
},
helpClass: {
type: String,
default: () => $ui.radio.help
},
customClass: {
type: String,
default: null
}
},
emits: ['update:modelValue', 'focus', 'blur'],
setup (props, { emit }) {
const isChecked = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const radioClass = computed(() => {
return classNames(
props.baseClass,
props.customClass
)
})
return {
isChecked,
radioClass
}
}
}
</script>

View File

@@ -0,0 +1,209 @@
<template>
<div :class="wrapperClass">
<div v-if="icon" :class="iconWrapperClass">
<Icon :name="icon" :class="iconClass" />
</div>
<select
:id="name"
:name="name"
:required="required"
:disabled="disabled"
:class="selectClass"
@input="onInput($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>
</template>
<script>
import { ref, computed } from 'vue'
import { get } from 'lodash-es'
import Icon from '../elements/Icon'
import { classNames } from '../../utils'
import $ui from '#build/ui'
export default {
components: {
Icon
},
props: {
modelValue: {
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: () => []
},
size: {
type: String,
default: 'md',
validator (value) {
return Object.keys($ui.select.size).includes(value)
}
},
wrapperClass: {
type: String,
default: () => $ui.select.wrapper
},
baseClass: {
type: String,
default: () => $ui.select.base
},
iconBaseClass: {
type: String,
default: () => $ui.select.icon.base
},
customClass: {
type: String,
default: null
},
textAttribute: {
type: String,
default: 'text'
},
valueAttribute: {
type: String,
default: 'value'
},
icon: {
type: String,
default: null
}
},
emits: ['update:modelValue'],
setup (props, { emit }) {
const select = ref(null)
const onInput = (value) => {
emit('update:modelValue', value)
}
const guessOptionValue = (option) => {
return get(option, props.valueAttribute, get(option, props.textAttribute))
}
const guessOptionText = (option) => {
return get(option, props.textAttribute, get(option, props.valueAttribute))
}
const normalizeOption = (option) => {
if (['string', 'number', 'boolean'].includes(typeof option)) {
return {
[props.valueAttribute]: option,
[props.textAttribute]: option
}
}
return {
...option,
[props.valueAttribute]: guessOptionValue(option),
[props.textAttribute]: guessOptionText(option)
}
}
const normalizedOptions = computed(() => {
return props.options.map(option => normalizeOption(option))
})
const normalizedOptionsWithPlaceholder = computed(() => {
if (!props.placeholder) {
return normalizedOptions.value
}
return [
{
[props.valueAttribute]: null,
[props.textAttribute]: props.placeholder
},
...normalizedOptions.value
]
})
const normalizedValue = computed(() => {
const foundOption = normalizedOptionsWithPlaceholder.value.find(option => option.value === props.modelValue)
if (!foundOption) {
return null
}
return foundOption.value
})
const selectClass = computed(() => {
return classNames(
props.baseClass,
$ui.select.size[props.size],
$ui.select.spacing[props.size],
$ui.select.appearance.default,
!!props.icon && $ui.select.leading.spacing[props.size],
$ui.select.trailing.spacing[props.size],
props.customClass
)
})
const iconClass = computed(() => {
return classNames(
props.iconBaseClass,
$ui.select.icon.size[props.size],
!!props.icon && $ui.select.icon.leading.spacing[props.size]
)
})
const iconWrapperClass = $ui.select.icon.leading.base
return {
select,
onInput,
guessOptionValue,
guessOptionText,
normalizeOption,
normalizedOptions,
normalizedOptionsWithPlaceholder,
normalizedValue,
selectClass,
iconClass,
iconWrapperClass
}
}
}
</script>

View File

@@ -0,0 +1,448 @@
<template>
<div ref="container">
<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="u-text-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="u-text-gray-700">{{ selectedOption[textAttribute] }}</span>
</slot>
</span>
</div>
<div v-else class="inline-flex w-full u-text-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 u-ring-gray-200" :class="dropdownClass">
<div v-if="searchable" class="w-full border-b u-border-gray-200">
<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,
'u-text-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,
'u-text-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-es'
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>

View File

@@ -0,0 +1,150 @@
<template>
<div :class="wrapperClass">
<textarea
:id="name"
ref="textarea"
:value="modelValue"
:name="name"
:rows="rows"
:required="required"
:disabled="disabled"
:placeholder="placeholder"
:autocomplete="autocomplete"
:class="textareaClass"
@input="onInput($event.target.value)"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
/>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
export default {
props: {
modelValue: {
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
},
autoresize: {
type: Boolean,
default: false
},
autofocus: {
type: Boolean,
default: false
},
autocomplete: {
type: String,
default: null
},
appearance: {
type: String,
default: 'default',
validator (value) {
return Object.keys($ui.textarea.appearance).includes(value)
}
},
resize: {
type: Boolean,
default: true
},
size: {
type: String,
default: 'md',
validator (value) {
return Object.keys($ui.textarea.size).includes(value)
}
},
wrapperClass: {
type: String,
default: () => $ui.textarea.wrapper
},
baseClass: {
type: String,
default: () => $ui.textarea.base
},
customClass: {
type: String,
default: null
}
},
emits: ['update:modelValue', 'focus', 'blur'],
setup (props, { emit }) {
const textarea = ref(null)
const autoFocus = () => {
if (props.autofocus) {
textarea.value.focus()
}
}
const autoResize = () => {
if (props.autoresize) {
const styles = window.getComputedStyle(textarea.value)
const paddingTop = parseInt(styles.paddingTop)
const paddingBottom = parseInt(styles.paddingBottom)
const padding = paddingTop + paddingBottom
const initialHeight = (parseInt(styles.height) - padding) / textarea.value.rows
const scrollHeight = textarea.value.scrollHeight - padding
const newRows = Math.ceil(scrollHeight / initialHeight)
textarea.value.rows = newRows
}
}
const onInput = (value) => {
autoResize()
emit('update:modelValue', value)
}
onMounted(() => {
setTimeout(() => {
autoFocus()
autoResize()
}, 100)
})
const textareaClass = computed(() => {
return classNames(
props.baseClass,
$ui.textarea.size[props.size],
$ui.textarea.spacing[props.size],
$ui.textarea.appearance[props.appearance],
!props.resize && 'resize-none',
props.customClass
)
})
return {
textarea,
onInput,
textareaClass
}
}
}
</script>

View File

@@ -0,0 +1,47 @@
<template>
<Switch
v-model="enabled"
:class="[enabled ? 'bg-primary-600' : 'u-bg-gray-200', 'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500']"
>
<span :class="[enabled ? 'translate-x-5' : 'translate-x-0', 'pointer-events-none relative inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200']">
<span :class="[enabled ? 'opacity-0 ease-out duration-100' : 'opacity-100 ease-in duration-200', 'absolute inset-0 h-full w-full flex items-center justify-center transition-opacity']" aria-hidden="true">
<Icon :name="iconOff" class="h-3 w-3 u-text-gray-400" />
</span>
<span :class="[enabled ? 'opacity-100 ease-in duration-200' : 'opacity-0 ease-out duration-100', 'absolute inset-0 h-full w-full flex items-center justify-center transition-opacity']" aria-hidden="true">
<Icon :name="iconOn" class="h-3 w-3 text-primary-600" />
</span>
</span>
</Switch>
</template>
<script setup>
import { computed } from 'vue'
import { Switch } from '@headlessui/vue'
import Icon from '../elements/Icon'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
iconOn: {
type: String,
default: ''
},
iconOff: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const enabled = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
</script>

View File

@@ -0,0 +1,107 @@
<template>
<component
:is="$attrs.onSubmit ? 'form': 'div'"
:class="cardClass"
v-bind="$attrs"
>
<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>
import { computed } from 'vue'
import { classNames } from '../../utils/'
import $ui from '#build/ui'
export default {
props: {
padded: {
type: Boolean,
default: false
},
rounded: {
type: Boolean,
default: true
},
baseClass: {
type: String,
default: () => $ui.card.base
},
backgroundClass: {
type: String,
default: () => $ui.card.background
},
borderColorClass: {
type: String,
default: () => $ui.card.border
},
shadowClass: {
type: String,
default: () => $ui.card.shadow
},
ringClass: {
type: String,
default: () => $ui.card.ring
},
bodyClass: {
type: String,
default: () => $ui.card.body
},
bodyBackgroundClass: {
type: String,
default: null
},
headerClass: {
type: String,
default: () => $ui.card.header
},
headerBackgroundClass: {
type: String,
default: null
},
footerClass: {
type: String,
default: () => $ui.card.footer
},
footerBackgroundClass: {
type: String,
default: null
},
customClass: {
type: String,
default: null
}
},
setup (props) {
const cardClass = computed(() => {
return classNames(
props.baseClass,
props.padded && props.rounded && 'rounded-md',
!props.padded && props.rounded && 'sm:rounded-md',
props.ringClass,
props.shadowClass,
props.backgroundClass,
props.customClass
)
})
return {
cardClass
}
}
}
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div :class="containerClass">
<slot />
</div>
</template>
<script>
import { computed } from 'vue'
import { classNames } from '../../utils/'
import $ui from '#build/ui'
export default {
props: {
padded: {
type: Boolean,
default: false
},
constrained: {
type: Boolean,
default: true
},
constrainedClass: {
type: String,
default: () => $ui.container.constrained
}
},
setup (props) {
const containerClass = computed(() => {
return classNames(
'mx-auto sm:px-6 lg:px-8',
props.padded && 'px-4',
props.constrained && props.constrainedClass
)
})
return {
containerClass
}
}
}
</script>

View File

@@ -0,0 +1,64 @@
<template>
<nav class="flex items-center space-x-1.5">
<div v-for="(link, index) of links" :key="index">
<Button
:size="size"
:to="link.to"
:label="link.label"
:icon="link.icon"
:variant="isActive(link) ? activeVariant : variant"
:custom-class="isActive(link) ? activeClass : ''"
@click="click(link)"
/>
</div>
</nav>
</template>
<script>
import Button from '../elements/Button'
export default {
components: {
Button
},
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: 'u-text-gray-700 hover:u-text-gray-700 focus:u-text-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>

View File

@@ -0,0 +1,39 @@
<template>
<nav class="flex items-center gap-6">
<Link
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 }}
</Link>
</nav>
</template>
<script>
import Link from '../elements/Link'
export default {
components: {
Link
},
props: {
links: {
type: Array,
required: true
},
activeClass: {
type: String,
default: 'u-border-black u-text-black hover:text-black dark:hover:text-white hover:border-black dark:hover:border-white'
},
inactiveClass: {
type: String,
default: 'border-transparent u-text-gray-500 hover:u-text-gray-700 hover:u-border-gray-300'
}
}
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<nav class="space-y-1">
<Link
v-for="(link, index) of links"
v-slot="{ isActive }"
:key="index"
v-bind="link"
:class="baseClass"
:active-class="activeClass"
:inactive-class="inactiveClass"
@click="link.click && link.click()"
@keyup.enter="$event.target.blur()"
>
<slot name="icon" :link="link">
<Icon
v-if="link.icon"
:name="link.icon"
:class="[iconBaseClass, isActive ? iconActiveClass : iconInactiveClass]"
/>
</slot>
<slot :link="link">
<span class="truncate">{{ link.label }}</span>
</slot>
<slot name="badge" :link="link">
<span v-if="link.badge" :class="[badgeBaseClass, isActive ? badgeActiveClass : badgeInactiveClass]">
{{ link.badge }}
</span>
</slot>
</Link>
</nav>
</template>
<script>
import Icon from '../elements/Icon'
import Link from '../elements/Link'
import $ui from '#build/ui'
export default {
components: {
Icon,
Link
},
props: {
links: {
type: Array,
required: true
},
baseClass: {
type: String,
default: () => $ui.verticalNavigation.base
},
activeClass: {
type: String,
default: () => $ui.verticalNavigation.active
},
inactiveClass: {
type: String,
default: () => $ui.verticalNavigation.inactive
},
iconBaseClass: {
type: String,
default: () => $ui.verticalNavigation.icon.base
},
iconActiveClass: {
type: String,
default: () => $ui.verticalNavigation.icon.active
},
iconInactiveClass: {
type: String,
default: () => $ui.verticalNavigation.icon.inactive
},
badgeBaseClass: {
type: String,
default: () => $ui.verticalNavigation.badge.base
},
badgeActiveClass: {
type: String,
default: () => $ui.verticalNavigation.badge.active
},
badgeInactiveClass: {
type: String,
default: () => $ui.verticalNavigation.badge.inactive
}
}
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<TransitionRoot :show="isOpen" as="template">
<Dialog @close="setIsOpen">
<div class="fixed z-10 inset-0 overflow-y-auto">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<DialogOverlay class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</TransitionChild>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Card
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
v-bind="$attrs"
ring-class
>
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<slot />
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</Card>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script>
import { computed } from 'vue'
import { Dialog, DialogOverlay, TransitionRoot, TransitionChild } from '@headlessui/vue'
import Card from '../layout/Card'
export default {
components: {
Dialog,
DialogOverlay,
TransitionRoot,
TransitionChild,
Card
},
props: {
modelValue: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue'],
setup (props, { emit }) {
const isOpen = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
return {
isOpen,
setIsOpen (value) {
isOpen.value = value
},
toggleIsOpen () {
isOpen.value = !isOpen.value
}
}
}
}
</script>

View File

@@ -0,0 +1,202 @@
<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"
@mouseleave="onMouseleave"
>
<div class="relative overflow-hidden rounded-lg ring-1 u-ring-gray-200">
<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 u-text-gray-900">
{{ title }}
</p>
<p v-if="description" class="mt-1 text-sm leading-5 u-text-gray-500">
{{ description }}
</p>
<Button
v-if="undo"
variant="white"
size="xs"
class="mt-2"
@click.stop="onUndo"
>
Undo
</Button>
</div>
<div class="flex-shrink-0 ml-4">
<button
class="transition duration-150 ease-in-out u-text-gray-400 focus:outline-none hover:u-text-gray-500 focus:u-text-gray-500"
@click.stop="onClose"
>
<span class="sr-only">Close</span>
<Icon name="heroicons-solid: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 { ref, computed, onMounted, onUnmounted, watchEffect } from 'vue'
import Icon from '../elements/Icon'
import Button from '../elements/Button'
import { useTimer } from '../../composables/useTimer'
export default {
components: {
Icon,
Button
},
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
}
},
emits: ['close'],
setup (props, { emit }) {
let timer = null
const remaining = ref(props.timeout)
const iconName = computed(() => {
return props.icon || ({
warning: 'heroicons-outline:exclamation-circle',
info: 'heroicons-outline:information-circle',
success: 'heroicons-outline:check-circle',
error: 'heroicons-outline:x-circle'
})[props.type]
})
const iconClass = computed(() => {
return ({
warning: 'text-orange-400',
info: 'text-blue-400',
success: 'text-green-400',
error: 'text-red-400'
})[props.type] || 'u-text-gray-400'
})
const progressBarStyle = computed(() => {
const remainingPercent = remaining.value / props.timeout * 100
return { width: `${remainingPercent || 0}%` }
})
function onMouseover () {
if (timer) {
timer.pause()
}
}
function onMouseleave () {
if (timer) {
timer.resume()
}
}
function onClose () {
if (timer) {
timer.stop()
}
if (props.callback) {
props.callback()
}
emit('close')
}
function onUndo () {
if (timer) {
timer.stop()
}
if (props.undo) {
props.undo()
}
emit('close')
}
onMounted(() => {
if (!props.timeout) {
return
}
timer = useTimer(() => {
onClose()
}, props.timeout)
watchEffect(() => {
remaining.value = timer.remaining.value
})
})
onUnmounted(() => {
timer.stop()
})
return {
timer,
iconName,
iconClass,
progressBarStyle,
onMouseover,
onMouseleave,
onClose,
onUndo
}
}
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div class="fixed bottom-0 right-0 flex flex-col justify-end w-full z-55 sm:w-96">
<div
v-if="notifications.length"
class="px-4 py-6 space-y-3 overflow-y-auto sm:px-6 lg:px-8"
>
<div
v-for="(notification, index) of notifications"
v-show="index === notifications.length - 1"
:key="notification.id"
>
<Notification
v-bind="notification"
:class="notification.click && 'cursor-pointer'"
@click="notification.click && notification.click(notification)"
@close="$toast.removeNotification(notification.id)"
/>
</div>
</div>
</div>
</template>
<script setup>
import Notification from './Notification'
import { useNuxtApp, useState } from '#app'
const { $toast } = useNuxtApp()
const notifications = useState('notifications')
</script>

View File

@@ -0,0 +1,91 @@
<template>
<Popover v-slot="{ open }" :class="wrapperClass">
<PopoverButton ref="trigger" as="div">
<slot :open="open">
<button>Open</button>
</slot>
</PopoverButton>
<div v-if="open" ref="container" :class="containerClass">
<transition
appear
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<PopoverPanel :class="panelClass" static>
<slot name="panel" />
</PopoverPanel>
</transition>
</div>
</Popover>
</template>
<script>
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import { usePopper } from '../../utils'
export default {
components: {
Popover,
PopoverButton,
PopoverPanel
},
props: {
placement: {
type: String,
default: 'bottom'
},
strategy: {
type: String,
default: 'fixed'
},
wrapperClass: {
type: String,
default: 'relative'
},
containerClass: {
type: String,
default: 'z-10'
},
panelClass: {
type: String,
default: 'transform'
}
},
setup (props) {
const [trigger, container] = usePopper({
placement: props.placement,
strategy: props.strategy,
modifiers: [{
name: 'offset',
options: {
offset: [0, 8]
}
},
{
name: 'computeStyles',
options: {
gpuAcceleration: false,
adaptive: false
}
},
{
name: 'preventOverflow',
options: {
padding: 8
}
}]
})
return {
trigger,
container
}
}
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div ref="trigger" :class="wrapperClass" @mouseover="open = true" @mouseleave="open = false">
<slot :open="open">
Hover me
</slot>
<div v-if="open" ref="container" :class="containerClass">
<transition
appear
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div :class="tooltipClass">
<slot name="text">
{{ text }}
</slot>
</div>
</transition>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import { usePopper } from '../../utils'
export default {
props: {
text: {
type: String,
default: null
},
placement: {
type: String,
default: 'bottom',
validator: (value) => {
return ['auto', 'auto-start', 'auto-end', 'top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end', 'right', 'right-start', 'right-end', 'left', 'left-start', 'left-end'].includes(value)
}
},
strategy: {
type: String,
default: 'fixed',
validator: (value) => {
return ['absolute', 'fixed'].includes(value)
}
},
wrapperClass: {
type: String,
default: 'relative inline-flex'
},
containerClass: {
type: String,
default: 'z-10'
},
tooltipClass: {
type: String,
default: 'flex items-center justify-center invisible w-auto h-6 max-w-xs px-2 space-x-1 truncate rounded shadow lg:visible u-bg-gray-800 truncate u-text-gray-50 text-xs'
}
},
setup (props) {
const open = ref(false)
const [trigger, container] = usePopper({
placement: props.placement,
strategy: props.strategy,
modifiers: [{
name: 'offset',
options: {
offset: [0, 8]
}
},
{
name: 'computeStyles',
options: {
gpuAcceleration: false,
adaptive: false
}
},
{
name: 'preventOverflow',
options: {
padding: 8
}
}]
})
return {
open,
trigger,
container
}
}
}
</script>