feat: rewrite to use app config and rework docs (#143)

Co-authored-by: Daniel Roe <daniel@roe.dev>
Co-authored-by: Sébastien Chopin <seb@nuxt.com>
This commit is contained in:
Benjamin Canac
2023-05-04 14:49:19 +02:00
committed by GitHub
parent 56230ea915
commit 6da0db0113
144 changed files with 10470 additions and 8109 deletions

View File

@@ -1,123 +1,135 @@
<template>
<span :class="wrapperClass">
<img v-if="url && !error" :class="avatarClass" :src="url" :alt="alt" :onerror="() => onError()">
<span v-else-if="text || placeholder" :class="placeholderClass">{{ text || placeholder }}</span>
<span v-else-if="text || placeholder" :class="ui.placeholder">{{ text || placeholder }}</span>
<span v-if="chip" :class="chipClass" />
<span v-if="chipColor" :class="chipClass" />
<slot />
</span>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
const props = defineProps({
src: {
type: [String, Boolean],
default: null
},
alt: {
type: String,
default: null
},
text: {
type: String,
default: null
},
size: {
type: String,
default: 'md',
validator (value: string) {
return Object.keys($ui.avatar.size).includes(value)
}
},
rounded: {
type: Boolean,
default: true
},
chip: {
type: String,
default: null,
validator (value: string) {
return Object.keys($ui.avatar.chip.variant).includes(value)
}
},
chipPosition: {
type: String,
default: 'top-right',
validator (value: string) {
return Object.keys($ui.avatar.chip.position).includes(value)
}
},
wrapperClass: {
type: String,
default: () => $ui.avatar.wrapper
},
backgroundClass: {
type: String,
default: () => $ui.avatar.background
},
placeholderClass: {
type: String,
default: () => $ui.avatar.placeholder
},
roundedClass: {
type: String,
default: () => $ui.avatar.rounded
}
})
const wrapperClass = computed(() => {
return classNames(
props.wrapperClass,
props.backgroundClass,
$ui.avatar.size[props.size],
props.rounded ? 'rounded-full' : props.roundedClass
)
})
const avatarClass = computed(() => {
return classNames(
$ui.avatar.size[props.size],
props.rounded ? 'rounded-full' : props.roundedClass
)
})
const chipClass = computed(() => {
return classNames(
$ui.avatar.chip.base,
$ui.avatar.chip.variant[props.chip],
$ui.avatar.chip.position[props.chipPosition],
$ui.avatar.chip.size[props.size]
)
})
const url = computed(() => {
if (typeof props.src === 'boolean') {
return null
}
return props.src
})
const placeholder = computed(() => {
return (props.alt || '').split(' ').map(word => word.charAt(0)).join('').substring(0, 2)
})
const error = ref(false)
watch(() => props.src, () => {
if (error.value) {
error.value = false
}
})
function onError () {
error.value = true
}
</script>
<script lang="ts">
export default { name: 'UAvatar' }
import { defineComponent, ref, computed, watch } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
props: {
src: {
type: [String, Boolean],
default: null
},
alt: {
type: String,
default: null
},
text: {
type: String,
default: null
},
size: {
type: String,
default: () => appConfig.ui.avatar.default.size,
validator (value: string) {
return Object.keys(appConfig.ui.avatar.size).includes(value)
}
},
chipColor: {
type: String,
default: null,
validator (value: string) {
return appConfig.ui.colors.includes(value)
}
},
chipVariant: {
type: String,
default: () => appConfig.ui.avatar.default.chipVariant,
validator (value: string) {
return Object.keys(appConfig.ui.avatar.chip.variant).includes(value)
}
},
chipPosition: {
type: String,
default: () => appConfig.ui.avatar.default.chipPosition,
validator (value: string) {
return Object.keys(appConfig.ui.avatar.chip.position).includes(value)
}
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.avatar>>,
default: () => appConfig.ui.avatar
}
},
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.avatar>>(() => defu({}, props.ui, appConfig.ui.avatar))
const wrapperClass = computed(() => {
return classNames(
ui.value.wrapper,
ui.value.background,
ui.value.rounded,
ui.value.size[props.size]
)
})
const avatarClass = computed(() => {
return classNames(
ui.value.rounded,
ui.value.size[props.size]
)
})
const chipClass = computed(() => {
return classNames(
ui.value.chip.base,
ui.value.chip.size[props.size],
ui.value.chip.position[props.chipPosition],
ui.value.chip.variant[props.chipVariant]?.replaceAll('{color}', props.chipColor)
)
})
const url = computed(() => {
if (typeof props.src === 'boolean') {
return null
}
return props.src
})
const placeholder = computed(() => {
return (props.alt || '').split(' ').map(word => word.charAt(0)).join('').substring(0, 2)
})
const error = ref(false)
watch(() => props.src, () => {
if (error.value) {
error.value = false
}
})
function onError () {
error.value = true
}
return {
wrapperClass,
avatarClass,
chipClass,
url,
placeholder,
error,
onError
}
}
})
</script>

View File

@@ -0,0 +1,80 @@
import { h, computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
import Avatar from './Avatar.vue'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
props: {
size: {
type: String,
default: null,
validator (value: string) {
return Object.keys(appConfig.ui.avatar.size).includes(value)
}
},
max: {
type: Number,
default: null
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.avatarGroup>>,
default: () => appConfig.ui.avatarGroup
}
},
setup (props, { slots }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.avatarGroup>>(() => defu({}, props.ui, appConfig.ui.avatarGroup))
const children = computed(() => {
let children = slots.default?.()
// @ts-ignore-next
if (children.length && children[0].type.name === 'ContentSlot') {
// @ts-ignore-next
children = children[0].ctx.slots.default?.()
}
return children
})
const max = computed(() => typeof props.max === 'string' ? parseInt(props.max, 10) : props.max)
const clones = computed(() => children.value.map((node, index) => {
if (!props.max || (max.value && index < max.value)) {
if (props.size) {
node.props.size = props.size
}
node.props.class = node.props.class || ''
node.props.class += ` ${classNames(
ui.value.ring,
ui.value.margin
)}`
return node
}
if (max.value !== undefined && index === max.value) {
return h(Avatar, {
size: props.size,
text: `+${children.value.length - max.value}`,
class: classNames(
ui.value.ring,
ui.value.margin
)
})
}
return null
}).filter(Boolean).reverse())
return () => h('div', { class: ui.value.wrapper }, clones.value)
}
})

View File

@@ -1,79 +0,0 @@
<template>
<div class="flex flex-row-reverse">
<Avatar
v-if="remainingGroupSize > 0"
:size="size"
:text="`+${remainingGroupSize}`"
:class="avatarClass"
/>
<Avatar
v-for="(avatar, index) of displayedGroup"
:key="index"
v-bind="avatar"
:size="size"
:class="avatarClass"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { classNames } from '../../utils'
import Avatar from './Avatar.vue'
import $ui from '#build/ui'
const props = defineProps({
group: {
type: Array,
default: () => []
},
size: {
type: String,
default: 'md',
validator (value: string) {
return Object.keys($ui.avatar.size).includes(value)
}
},
max: {
type: Number,
default: null
},
ringClass: {
type: String,
default: () => $ui.avatarGroup.ring
},
marginClass: {
type: String,
default: () => $ui.avatarGroup.margin
}
})
const avatars = computed(() => {
return props.group.map((avatar) => {
return typeof avatar === 'string' ? { src: avatar } : avatar
})
})
const displayedGroup = computed(() => {
if (!props.max) { return [...avatars.value].reverse() }
return avatars.value.slice(0, props.max).reverse()
})
const remainingGroupSize = computed(() => {
if (!props.max) { return 0 }
return avatars.value.length - props.max
})
const avatarClass = computed(() => {
return classNames(
props.ringClass,
props.marginClass
)
})
</script>
<script lang="ts">
export default { name: 'UAvatarGroup' }
</script>

View File

@@ -4,50 +4,69 @@
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
<script lang="ts">
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
import $ui from '#build/ui'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
size: {
type: String,
default: 'md',
validator (value: string) {
return Object.keys($ui.badge.size).includes(value)
// const appConfig = useAppConfig()
export default defineComponent({
props: {
size: {
type: String,
default: () => appConfig.ui.badge.default.size,
validator (value: string) {
return Object.keys(appConfig.ui.badge.size).includes(value)
}
},
color: {
type: String,
default: () => appConfig.ui.badge.default.color,
validator (value: string) {
return appConfig.ui.colors.includes(value)
}
},
variant: {
type: String,
default: () => appConfig.ui.badge.default.variant,
validator (value: string) {
return Object.keys(appConfig.ui.badge.variant).includes(value)
}
},
label: {
type: String,
default: null
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.badge>>,
default: () => appConfig.ui.badge
}
},
variant: {
type: String,
default: 'primary',
validator (value: string) {
return Object.keys($ui.badge.variant).includes(value)
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.badge>>(() => defu({}, props.ui, appConfig.ui.badge))
const badgeClass = computed(() => {
return classNames(
ui.value.base,
ui.value.font,
ui.value.rounded,
ui.value.size[props.size],
ui.value.variant[props.variant]?.replaceAll('{color}', props.color)
)
})
return {
badgeClass
}
},
baseClass: {
type: String,
default: () => $ui.badge.base
},
rounded: {
type: Boolean,
default: false
},
label: {
type: String,
default: null
}
})
const badgeClass = computed(() => {
return classNames(
props.baseClass,
$ui.badge.size[props.size],
$ui.badge.variant[props.variant],
props.rounded ? 'rounded-full' : 'rounded-md'
)
})
</script>
<script lang="ts">
export default { name: 'UBadge' }
</script>

View File

@@ -8,220 +8,224 @@
>
<Icon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="leadingIconClass" aria-hidden="true" />
<slot>
<span :class="[truncate ? 'text-left break-all line-clamp-1' : '', compact ? 'hidden sm:block' : '']">
<span :class="[labelCompact && 'hidden sm:block']">{{ label }}</span>
<span v-if="labelCompact" class="sm:hidden">{{ labelCompact }}</span>
<span v-if="label" :class="[truncate ? 'text-left break-all line-clamp-1' : '']">
{{ label }}
</span>
</slot>
<Icon v-if="isTrailing && trailingIconName" :name="trailingIconName" :class="trailingIconClass" aria-hidden="true" />
</component>
</template>
<script setup lang="ts">
import { ref, computed, useSlots } from 'vue'
<script lang="ts">
import { ref, computed, defineComponent, useSlots } from 'vue'
import type { PropType } from 'vue'
import type { RouteLocationNormalized, RouteLocationRaw } from 'vue-router'
import NuxtLink from '#app/components/nuxt-link'
import { defu } from 'defu'
import Icon from '../elements/Icon.vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
import { NuxtLink } from '#components'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
type: {
type: String,
default: 'button'
// const appConfig = useAppConfig()
export default defineComponent({
components: {
Icon
},
block: {
type: Boolean,
default: false
},
label: {
type: String,
default: null
},
labelCompact: {
type: String,
default: null
},
loading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'md',
validator (value: string) {
return Object.keys($ui.button.size).includes(value)
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
},
padded: {
type: Boolean,
default: true
},
size: {
type: String,
default: () => appConfig.ui.button.default.size,
validator (value: string) {
return Object.keys(appConfig.ui.button.size).includes(value)
}
},
color: {
type: String,
default: () => appConfig.ui.button.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(appConfig.ui.button.color)].includes(value)
}
},
variant: {
type: String,
default: () => appConfig.ui.button.default.variant,
validator (value: string) {
return Object.keys(appConfig.ui.button.variant).includes(value)
}
},
icon: {
type: String,
default: null
},
loadingIcon: {
type: String,
default: () => appConfig.ui.button.default.loadingIcon
},
leadingIcon: {
type: String,
default: null
},
trailingIcon: {
type: String,
default: null
},
trailing: {
type: Boolean,
default: false
},
leading: {
type: Boolean,
default: false
},
to: {
type: [String, Object] as PropType<string | RouteLocationNormalized | RouteLocationRaw>,
default: null
},
target: {
type: String,
default: null
},
ariaLabel: {
type: String,
default: null
},
square: {
type: Boolean,
default: false
},
truncate: {
type: Boolean,
default: false
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.button>>,
default: () => appConfig.ui.button
}
},
variant: {
type: String,
default: 'primary',
validator (value: string) {
return Object.keys($ui.button.variant).includes(value)
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.button>>(() => defu({}, props.ui, appConfig.ui.button))
const slots = useSlots()
const button = ref(null)
const buttonIs = computed(() => {
if (props.to) {
return NuxtLink
}
return 'button'
})
const buttonProps = computed(() => {
if (props.to) {
return { to: props.to, target: props.target }
} else {
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) || props.leadingIcon
})
const isTrailing = computed(() => {
return (props.icon && props.trailing) || (props.loading && props.trailing) || props.trailingIcon
})
const isSquare = computed(() => props.square || (!slots.default && !props.label))
const buttonClass = computed(() => {
const variant = ui.value.color?.[props.color as string]?.[props.variant as string] || ui.value.variant[props.variant]
return classNames(
ui.value.base,
ui.value.font,
ui.value.rounded,
ui.value.size[props.size],
ui.value.gap[props.size],
props.padded && ui.value[isSquare.value ? 'square' : 'spacing'][props.size],
variant?.replaceAll('{color}', props.color),
props.block ? 'w-full flex justify-center items-center' : 'inline-flex items-center'
)
})
const leadingIconName = computed(() => {
if (props.loading) {
return props.loadingIcon
}
return props.leadingIcon || props.icon
})
const trailingIconName = computed(() => {
if (props.loading && !isLeading.value) {
return props.loadingIcon
}
return props.trailingIcon || props.icon
})
const leadingIconClass = computed(() => {
return classNames(
ui.value.icon.base,
ui.value.icon.size[props.size],
props.loading && 'animate-spin'
)
})
const trailingIconClass = computed(() => {
return classNames(
ui.value.icon.base,
ui.value.icon.size[props.size],
props.loading && !isLeading.value && 'animate-spin'
)
})
return {
button,
buttonIs,
buttonProps,
isLeading,
isTrailing,
isSquare,
buttonClass,
leadingIconName,
trailingIconName,
leadingIconClass,
trailingIconClass
}
},
icon: {
type: String,
default: null
},
leadingIcon: {
type: String,
default: null
},
trailingIcon: {
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] as PropType<string | RouteLocationNormalized | RouteLocationRaw>,
default: null
},
target: {
type: String,
default: null
},
ariaLabel: {
type: String,
default: null
},
rounded: {
type: Boolean,
default: false
},
roundedClass: {
type: String,
default: () => $ui.button.rounded
},
baseClass: {
type: String,
default: () => $ui.button.base
},
iconBaseClass: {
type: String,
default: () => $ui.button.icon.base
},
leadingIconClass: {
type: String,
default: ''
},
trailingIconClass: {
type: String,
default: ''
},
customClass: {
type: String,
default: null
},
square: {
type: Boolean,
default: false
},
truncate: {
type: Boolean,
default: false
},
compact: {
type: Boolean,
default: false
}
})
const slots = useSlots()
const button = ref(null)
const buttonIs = computed(() => {
if (props.to) {
return NuxtLink
}
return 'button'
})
const buttonProps = computed(() => {
if (props.to) {
return { to: props.to, target: props.target }
} else {
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) || props.leadingIcon
})
const isTrailing = computed(() => {
return (props.icon && props.trailing) || (props.loading && props.trailing) || props.trailingIcon
})
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' : (props.compact ? 'compact' : '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' : props.roundedClass,
props.customClass
)
})
const leadingIconName = computed(() => {
if (props.loading) {
return props.loadingIcon
}
return props.leadingIcon || props.icon
})
const trailingIconName = computed(() => {
if (props.loading && !isLeading.value) {
return props.loadingIcon
}
return props.trailingIcon || props.icon
})
const leadingIconClass = computed(() => {
return classNames(
props.iconBaseClass,
$ui.button.icon.size[props.size],
(!!slots.default || !!props.label?.length) && $ui.button.icon.leading[props.compact ? 'compactSpacing' : 'spacing'][props.size],
props.leadingIconClass,
props.loading && 'animate-spin'
)
})
const trailingIconClass = computed(() => {
return classNames(
props.iconBaseClass,
$ui.button.icon.size[props.size],
(!!slots.default || !!props.label?.length) && $ui.button.icon.trailing[props.compact ? 'compactSpacing' : 'spacing'][props.size],
props.trailingIconClass,
props.loading && !isLeading.value && 'animate-spin'
)
})
</script>
<script lang="ts">
export default { name: 'UButton' }
</script>

View File

@@ -0,0 +1,80 @@
import { h, computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
props: {
size: {
type: String,
default: null,
validator (value: string) {
return Object.keys(appConfig.ui.avatar.size).includes(value)
}
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.buttonGroup>>,
default: () => appConfig.ui.buttonGroup
}
},
setup (props, { slots }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.buttonGroup>>(() => defu({}, props.ui, appConfig.ui.buttonGroup))
const children = computed(() => {
let children = slots.default?.()
// @ts-ignore-next
if (children.length && children[0].type.name === 'ContentSlot') {
// @ts-ignore-next
children = children[0].ctx.slots.default?.()
}
return children
})
const rounded = computed(() => ({
'rounded-none': { left: 'rounded-l-none', right: 'rounded-r-none' },
'rounded-sm': { left: 'rounded-l-sm', right: 'rounded-r-sm' },
rounded: { left: 'rounded-l', right: 'rounded-r' },
'rounded-md': { left: 'rounded-l-md', right: 'rounded-r-md' },
'rounded-lg': { left: 'rounded-l-lg', right: 'rounded-r-lg' },
'rounded-xl': { left: 'rounded-l-xl', right: 'rounded-r-xl' },
'rounded-2xl': { left: 'rounded-l-2xl', right: 'rounded-r-2xl' },
'rounded-3xl': { left: 'rounded-l-3xl', right: 'rounded-r-3xl' },
'rounded-full': { left: 'rounded-l-full', right: 'rounded-r-full' }
}[ui.value.rounded]))
const clones = computed(() => children.value.map((node, index) => {
if (props.size) {
node.props.size = props.size
}
node.props.class = node.props.class || ''
node.props.class += ' !shadow-none'
node.props.ui = node.props.ui || {}
node.props.ui.rounded = ''
if (index === 0) {
node.props.ui.rounded = rounded.value.left
}
if (index > 0) {
node.props.class += ' -ml-px'
}
if (index === children.value.length - 1) {
node.props.ui.rounded = rounded.value.right
}
return node
}))
return () => h('div', { class: [ui.value.wrapper, ui.value.rounded, ui.value.shadow] }, clones.value)
}
})

View File

@@ -1,5 +1,5 @@
<template>
<Menu v-slot="{ open }" as="div" :class="wrapperClass" @mouseleave="onMouseLeave">
<Menu v-slot="{ open }" as="div" :class="ui.wrapper" @mouseleave="onMouseLeave">
<MenuButton
ref="trigger"
as="div"
@@ -8,17 +8,17 @@
role="button"
@mouseover="onMouseOver"
>
<slot :open="open">
<slot :open="open" :disabled="disabled">
<button :disabled="disabled">
Open
</button>
</slot>
</MenuButton>
<div v-if="open && items.length" ref="container" :class="[containerClass, widthClass]" @mouseover="onMouseOver">
<transition appear v-bind="transitionClass">
<MenuItems :class="[baseClass, divideClass, ringClass, roundedClass, shadowClass, backgroundClass]" static>
<div v-for="(subItems, index) of items" :key="index" :class="groupClass">
<div v-if="open && items.length" ref="container" :class="[ui.container, ui.width]" @mouseover="onMouseOver">
<transition appear v-bind="ui.transition">
<MenuItems :class="[ui.base, ui.divide, ui.ring, ui.rounded, ui.shadow, ui.background]" static>
<div v-for="(subItems, index) of items" :key="index" :class="ui.spacing">
<MenuItem v-for="(item, subIndex) of subItems" :key="subIndex" v-slot="{ active, disabled: itemDisabled }" :disabled="item.disabled">
<Component
v-bind="omit(item, ['click'])"
@@ -26,13 +26,13 @@
:class="resolveItemClass({ active, disabled: itemDisabled })"
@click="item.click"
>
<slot :name="item.slot" :item="item">
<Icon v-if="item.icon" :name="item.icon" :class="[itemIconClass, item.iconClass]" />
<Avatar v-if="item.avatar" v-bind="{ size: 'xxs', ...item.avatar }" :class="itemAvatarClass" />
<slot :name="item.slot || 'item'" :item="item">
<Icon v-if="item.icon" :name="item.icon" :class="[ui.item.icon.base, active ? ui.item.icon.active : ui.item.icon.inactive, item.iconClass]" />
<Avatar v-else-if="item.avatar" v-bind="{ size: ui.item.avatar.size, ...item.avatar }" :class="ui.item.avatar.base" />
<span class="truncate">{{ item.label }}</span>
<span v-if="item.shortcuts?.length" :class="itemShortcutsClass">
<span v-if="item.shortcuts?.length" :class="ui.item.shortcuts">
<kbd v-for="shortcut of item.shortcuts" :key="shortcut" class="font-sans">{{ shortcut }}</kbd>
</span>
</slot>
@@ -45,29 +45,39 @@
</Menu>
</template>
<script setup lang="ts">
import {
Menu,
MenuButton,
MenuItems,
MenuItem
} from '@headlessui/vue'
<script lang="ts">
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import type { PropType } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { defineComponent, ref, computed, onMounted } from 'vue'
import { defu } from 'defu'
import NuxtLink from '#app/components/nuxt-link'
import Icon from '../elements/Icon.vue'
import Avatar from '../elements/Avatar.vue'
import { classNames, omit } from '../../utils'
import { usePopper } from '../../composables/usePopper'
import type { Avatar as AvatarType } from '../../types/avatar'
import type { PopperOptions } from '../../types'
import $ui from '#build/ui'
import { NuxtLink } from '#components'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
items: {
type: Array as PropType<{
// const appConfig = useAppConfig()
export default defineComponent({
components: {
// eslint-disable-next-line vue/no-reserved-component-names
Menu,
MenuButton,
MenuItems,
MenuItem,
Icon,
Avatar
},
props: {
items: {
type: Array as PropType<{
to?: RouteLocationNormalized
exact?: boolean
label: string
@@ -79,176 +89,123 @@ const props = defineProps({
click?: Function
shortcuts?: string[]
}[][]>,
default: () => []
},
mode: {
type: String,
default: 'click',
validator: (value: string) => {
return ['click', 'hover'].includes(value)
default: () => []
},
mode: {
type: String,
default: 'click',
validator: (value: string) => {
return ['click', 'hover'].includes(value)
}
},
disabled: {
type: Boolean,
default: false
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
openDelay: {
type: Number,
default: 50
},
closeDelay: {
type: Number,
default: 0
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.dropdown>>,
default: () => appConfig.ui.dropdown
}
},
disabled: {
type: Boolean,
default: false
},
wrapperClass: {
type: String,
default: () => $ui.dropdown.wrapper
},
containerClass: {
type: String,
default: () => $ui.dropdown.container
},
widthClass: {
type: String,
default: () => $ui.dropdown.width
},
backgroundClass: {
type: String,
default: () => $ui.dropdown.background
},
shadowClass: {
type: String,
default: () => $ui.dropdown.shadow
},
roundedClass: {
type: String,
default: () => $ui.dropdown.rounded
},
ringClass: {
type: String,
default: () => $ui.dropdown.ring
},
divideClass: {
type: String,
default: () => $ui.dropdown.divide
},
baseClass: {
type: String,
default: () => $ui.dropdown.base
},
transitionClass: {
type: Object,
default: () => $ui.dropdown.transition
},
groupClass: {
type: String,
default: () => $ui.dropdown.group
},
itemBaseClass: {
type: String,
default: () => $ui.dropdown.item.base
},
itemActiveClass: {
type: String,
default: () => $ui.dropdown.item.active
},
itemInactiveClass: {
type: String,
default: () => $ui.dropdown.item.inactive
},
itemDisabledClass: {
type: String,
default: () => $ui.dropdown.item.disabled
},
itemIconClass: {
type: String,
default: () => $ui.dropdown.item.icon
},
itemAvatarClass: {
type: String,
default: () => $ui.dropdown.item.avatar
},
itemShortcutsClass: {
type: String,
default: () => $ui.dropdown.item.shortcuts
},
popperOptions: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
openDelay: {
type: Number,
default: 50
},
closeDelay: {
type: Number,
default: 0
}
})
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const popperOptions = computed<PopperOptions>(() => defu({}, props.popperOptions, $ui.dropdown.popperOptions))
const ui = computed<Partial<typeof appConfig.ui.dropdown>>(() => defu({}, props.ui, appConfig.ui.dropdown))
const [trigger, container] = usePopper(popperOptions.value)
const popper = computed<PopperOptions>(() => defu({}, props.popper, ui.value.popper as PopperOptions))
function resolveItemClass ({ active, disabled }: { active: boolean, disabled: boolean }) {
return classNames(
props.itemBaseClass,
active ? props.itemActiveClass : props.itemInactiveClass,
disabled && props.itemDisabledClass
)
}
const [trigger, container] = usePopper(popper.value)
// https://github.com/tailwindlabs/headlessui/blob/f66f4926c489fc15289d528294c23a3dc2aee7b1/packages/%40headlessui-vue/src/components/menu/menu.ts#L131
const menuApi = ref<any>(null)
let openTimeout: NodeJS.Timeout | null = null
let closeTimeout: NodeJS.Timeout | null = null
onMounted(() => {
setTimeout(() => {
// @ts-expect-error internals
const menuProvides = trigger.value?.$.provides
if (!menuProvides) {
return
function resolveItemClass ({ active, disabled }: { active: boolean, disabled: boolean }) {
return classNames(
ui.value.item.base,
active ? ui.value.item.active : ui.value.item.inactive,
disabled && ui.value.item.disabled
)
}
const menuProvidesSymbols = Object.getOwnPropertySymbols(menuProvides)
menuApi.value = menuProvidesSymbols.length && menuProvides[menuProvidesSymbols[0]]
}, 200)
// https://github.com/tailwindlabs/headlessui/blob/f66f4926c489fc15289d528294c23a3dc2aee7b1/packages/%40headlessui-vue/src/components/menu/menu.ts#L131
const menuApi = ref<any>(null)
let openTimeout: NodeJS.Timeout | null = null
let closeTimeout: NodeJS.Timeout | null = null
onMounted(() => {
setTimeout(() => {
// @ts-expect-error internals
const menuProvides = trigger.value?.$.provides
if (!menuProvides) {
return
}
const menuProvidesSymbols = Object.getOwnPropertySymbols(menuProvides)
menuApi.value = menuProvidesSymbols.length && menuProvides[menuProvidesSymbols[0]]
}, 200)
})
function onMouseOver () {
if (props.mode !== 'hover' || !menuApi.value) {
return
}
// cancel programmed closing
if (closeTimeout) {
clearTimeout(closeTimeout)
closeTimeout = null
}
// dropdown already open
if (menuApi.value.menuState === 0) {
return
}
openTimeout = openTimeout || setTimeout(() => {
menuApi.value.openMenu && menuApi.value.openMenu()
openTimeout = null
}, props.openDelay)
}
function onMouseLeave () {
if (props.mode !== 'hover' || !menuApi.value) {
return
}
// cancel programmed opening
if (openTimeout) {
clearTimeout(openTimeout)
openTimeout = null
}
// dropdown already closed
if (menuApi.value.menuState === 1) {
return
}
closeTimeout = closeTimeout || setTimeout(() => {
menuApi.value.closeMenu && menuApi.value.closeMenu()
closeTimeout = null
}, props.closeDelay)
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
trigger,
container,
resolveItemClass,
onMouseOver,
onMouseLeave,
omit,
NuxtLink
}
}
})
function onMouseOver () {
if (props.mode !== 'hover' || !menuApi.value) {
return
}
// cancel programmed closing
if (closeTimeout) {
clearTimeout(closeTimeout)
closeTimeout = null
}
// dropdown already open
if (menuApi.value.menuState === 0) {
return
}
openTimeout = openTimeout || setTimeout(() => {
menuApi.value.openMenu && menuApi.value.openMenu()
openTimeout = null
}, props.openDelay)
}
function onMouseLeave () {
if (props.mode !== 'hover' || !menuApi.value) {
return
}
// cancel programmed opening
if (openTimeout) {
clearTimeout(openTimeout)
openTimeout = null
}
// dropdown already closed
if (menuApi.value.menuState === 1) {
return
}
closeTimeout = closeTimeout || setTimeout(() => {
menuApi.value.closeMenu && menuApi.value.closeMenu()
closeTimeout = null
}, props.closeDelay)
}
</script>
<script lang="ts">
export default { name: 'UDropdown' }
</script>

View File

@@ -10,7 +10,3 @@ defineProps({
}
})
</script>
<script lang="ts">
export default { name: 'UIcon' }
</script>

View File

@@ -1,73 +0,0 @@
<template>
<button v-if="isButton" v-bind="$attrs" :class="inactiveClass">
<slot />
</button>
<a v-else-if="isExternalLink" v-bind="$attrs" :href="to" :target="target" :class="inactiveClass">
<slot />
</a>
<router-link
v-else
v-slot="{ href, navigate, isActive, isExactActive }"
v-bind="$props as RouterLinkProps"
custom
>
<a
v-bind="$attrs"
:href="href"
:class="resolveLinkClass({ isActive, isExactActive })"
@click="navigate"
>
<slot v-bind="{ isActive: exact ? isExactActive : isActive }" />
</a>
</router-link>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import type { RouteLocationNormalized, RouterLinkProps } from 'vue-router'
import { RouterLink } from 'vue-router'
const props = defineProps({
// @ts-expect-error internal props
...RouterLink.props,
to: {
type: [String, Object] as PropType<string | RouteLocationNormalized>,
default: null
},
inactiveClass: {
type: String,
default: ''
},
exact: {
type: Boolean,
default: false
},
target: {
type: String,
default: null
}
})
const isExternalLink = computed(() => {
return typeof props.to === 'string' && props.to.startsWith('http')
})
const isButton = computed(() => {
return !props.to
})
function resolveLinkClass ({ isActive, isExactActive }: { isActive: boolean, isExactActive: boolean }) {
if (props.exact) {
return isExactActive ? props.activeClass : props.inactiveClass
} else {
return isActive ? props.activeClass : props.inactiveClass
}
}
</script>
<script lang="ts">
export default {
name: 'ULink',
inheritAttrs: false
}
</script>

View File

@@ -0,0 +1,36 @@
<template>
<NuxtLink
v-slot="{ href, navigate, exact, isActive, isExactActive }"
custom
>
<a
v-bind="$attrs"
:href="href"
:class="resolveLinkClass({ isActive, isExactActive })"
@click="navigate"
>
<slot v-bind="{ isActive: exact ? isExactActive : isActive }" />
</a>
</NuxtLink>
</template>
<script setup lang="ts">
const props = defineProps({
activeClass: {
type: String,
default: ''
},
inactiveClass: {
type: String,
default: ''
}
})
function resolveLinkClass ({ isActive, isExactActive }: { isActive: boolean, isExactActive: boolean }) {
if (isActive || isExactActive) {
return props.activeClass
}
return props.inactiveClass
}
</script>

View File

@@ -1,106 +0,0 @@
<template>
<div class="rounded-md p-4" :class="variantClass">
<div class="flex">
<div class="inline-flex flex-shrink-0">
<Icon v-if="iconName" :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"
@click="click && click()"
>
{{ link }} &rarr;
</NuxtLink>
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
import Icon from '../elements/Icon.vue'
const props = defineProps({
variant: {
type: String,
default: 'info',
validator (value: string) {
return ['info', 'warning', 'error', 'success'].includes(value)
}
},
to: {
type: [String, Object] as PropType<string | RouteLocationNormalized>,
default: null
},
click: {
type: Function,
default: null
},
title: {
type: String,
default: null
},
link: {
type: String,
default: null
}
})
const iconName = computed(() => {
return ({
info: 'i-heroicons-information-circle',
warning: 'i-heroicons-exclamation',
error: 'i-heroicons-x-circle',
success: 'i-heroicons-check-circle'
})[props.variant]
})
const variantClass = computed(() => {
return ({
info: 'bg-blue-50',
warning: 'bg-orange-50',
error: 'bg-red-50',
success: 'bg-green-50'
})[props.variant]
})
const iconClass = computed(() => {
return ({
info: 'text-blue-400',
warning: 'text-orange-400',
error: 'text-red-400',
success: 'text-green-400'
})[props.variant]
})
const titleClass = computed(() => {
return ({
info: 'text-blue-700',
warning: 'text-orange-700',
error: 'text-red-700',
success: 'text-green-700'
})[props.variant]
})
const linkClass = computed(() => {
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'
})[props.variant]
})
</script>
<script lang="ts">
export default { name: 'UAlert' }
</script>

View File

@@ -1,108 +0,0 @@
<template>
<Modal v-model="isOpen" :appear="appear" class="w-full" @close="onClose">
<div class="sm:flex sm:items-start">
<div v-if="icon" :class="iconWrapperClass" class="mx-auto flex-shrink-0 flex items-center justify-center rounded-full sm:mx-0">
<Icon :name="icon" :class="iconClass" />
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left space-y-2">
<DialogTitle v-if="title" as="h3" :class="titleClass">
{{ title }}
</DialogTitle>
<DialogDescription v-if="description" as="p" :class="descriptionClass">
{{ description }}
</DialogDescription>
</div>
</div>
<div class="mt-5 space-y-3 sm:space-y-0 sm:mt-4 sm:flex sm:gap-3" :class="{ 'sm:flex-row-reverse': !leadingButtons }">
<Button variant="primary" :label="confirmLabel" block custom-class="sm:w-auto" @click="onConfirm" />
<Button variant="secondary" :label="cancelLabel" block custom-class="sm:w-auto" @click="onCancel" />
</div>
</Modal>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { DialogTitle, DialogDescription } from '@headlessui/vue'
import Icon from '../elements/Icon.vue'
import Modal from '../overlays/Modal.vue'
import Button from '../elements/Button.vue'
import $ui from '#build/ui'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
appear: {
type: Boolean,
default: false
},
icon: {
type: String,
default: ''
},
iconClass: {
type: String,
default: () => $ui.alertDialog.icon.base
},
iconWrapperClass: {
type: String,
default: () => $ui.alertDialog.icon.wrapper
},
title: {
type: String,
default: ''
},
titleClass: {
type: String,
default: () => $ui.alertDialog.title
},
description: {
type: String,
default: ''
},
descriptionClass: {
type: String,
default: () => $ui.alertDialog.description
},
confirmLabel: {
type: String,
default: 'Confirm'
},
cancelLabel: {
type: String,
default: 'Cancel'
},
leadingButtons: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel', 'close'])
const isOpen = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
function onConfirm () {
emit('confirm')
isOpen.value = false
}
function onCancel () {
emit('cancel')
isOpen.value = false
}
function onClose () {
emit('close')
}
</script>
<script lang="ts">
export default { name: 'UAlertDialog' }
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="wrapperClass">
<div :class="ui.wrapper">
<div class="flex items-center h-5">
<input
:id="name"
@@ -9,102 +9,90 @@
:value="value"
:disabled="disabled"
type="checkbox"
:class="inputClass"
:class="[ui.base, ui.custom]"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
>
</div>
<div v-if="label || $slots.label" class="ml-3 text-sm">
<label :for="name" :class="labelClass">
<label :for="name" :class="ui.label">
<slot name="label">{{ label }}</slot>
<span v-if="required" :class="requiredClass">*</span>
<span v-if="required" :class="ui.required">*</span>
</label>
<p v-if="help" :class="helpClass">
<p v-if="help" :class="ui.help">
{{ help }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
const props = defineProps({
value: {
type: [String, Number, Boolean],
default: null
},
modelValue: {
type: [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
}
})
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
const isChecked = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const inputClass = computed(() => {
return classNames(
props.baseClass,
props.customClass
)
})
</script>
<script lang="ts">
export default { name: 'UCheckbox' }
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
props: {
value: {
type: [String, Number, Boolean],
default: null
},
modelValue: {
type: [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
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.checkbox>>,
default: () => appConfig.ui.checkbox
}
},
emits: ['update:modelValue', 'focus', 'blur'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.checkbox>>(() => defu({}, props.ui, appConfig.ui.checkbox))
const isChecked = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
isChecked
}
}
})
</script>

View File

@@ -1,89 +0,0 @@
<template>
<div :class="wrapperClass">
<div v-if="label || $slots.label" :class="labelWrapperClass">
<label :for="name" :class="labelClass">
<slot name="label">{{ label }}</slot>
<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>
<div :class="!!label && containerClass">
<slot />
<p v-if="help" :class="helpClass">
{{ help }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import $ui from '#build/ui'
defineProps({
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
},
wrapperClass: {
type: String,
default: () => $ui.formGroup.wrapper
},
containerClass: {
type: String,
default: () => $ui.formGroup.container
},
labelClass: {
type: String,
default: () => $ui.formGroup.label
},
labelWrapperClass: {
type: String,
default: () => $ui.formGroup.labelWrapper
},
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>
<script lang="ts">
export default { name: 'UFormGroup' }
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="wrapperClass">
<div :class="ui.wrapper">
<input
:id="name"
ref="input"
@@ -8,7 +8,7 @@
:type="type"
:required="required"
:placeholder="placeholder"
:disabled="disabled"
:disabled="disabled || loading"
:readonly="readonly"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
@@ -18,176 +18,216 @@
@blur="$emit('blur', $event)"
>
<slot />
<div v-if="isLeading" :class="iconLeadingWrapperClass">
<Icon v-if="iconName" :name="iconName" :class="iconClass" />
<div v-if="isLeading && leadingIconName" :class="leadingIconClass">
<Icon :name="leadingIconName" :class="iconClass" />
</div>
<div v-if="isTrailing" :class="iconTrailingWrapperClass">
<Icon v-if="iconName" :name="iconName" :class="iconClass" />
<div v-if="isTrailing && trailingIconName" :class="trailingIconClass">
<Icon :name="trailingIconName" :class="iconClass" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
<script lang="ts">
import { ref, computed, onMounted, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import Icon from '../elements/Icon.vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
// const appConfig = useAppConfig()
export default defineComponent({
components: {
Icon
},
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: Boolean,
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: string) {
return Object.keys($ui.input.size).includes(value)
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: Boolean,
default: null
},
icon: {
type: String,
default: null
},
loadingIcon: {
type: String,
default: () => appConfig.ui.input.default.loadingIcon
},
leadingIcon: {
type: String,
default: null
},
trailingIcon: {
type: String,
default: null
},
trailing: {
type: Boolean,
default: false
},
leading: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
size: {
type: String,
default: () => appConfig.ui.input.default.size,
validator (value: string) {
return Object.keys(appConfig.ui.input.size).includes(value)
}
},
appearance: {
type: String,
default: () => appConfig.ui.input.default.appearance,
validator (value: string) {
return Object.keys(appConfig.ui.input.appearance).includes(value)
}
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.input>>,
default: () => appConfig.ui.input
}
},
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: string) {
return Object.keys($ui.input.appearance).includes(value)
emits: ['update:modelValue', 'focus', 'blur'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.input>>(() => defu({}, props.ui, appConfig.ui.input))
const input = ref<HTMLInputElement | null>(null)
const autoFocus = () => {
if (props.autofocus) {
input.value?.focus()
}
}
const onInput = (value: string) => {
emit('update:modelValue', value)
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, 100)
})
const inputClass = computed(() => {
return classNames(
ui.value.base,
ui.value.size[props.size],
ui.value.spacing[props.size],
ui.value.appearance[props.appearance],
isLeading.value && ui.value.leading.spacing[props.size],
isTrailing.value && ui.value.trailing.spacing[props.size],
ui.value.custom
)
})
const isLeading = computed(() => {
return (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing) || props.leadingIcon
})
const isTrailing = computed(() => {
return (props.icon && props.trailing) || (props.loading && props.trailing) || props.trailingIcon
})
const leadingIconName = computed(() => {
if (props.loading) {
return props.loadingIcon
}
return props.leadingIcon || props.icon
})
const trailingIconName = computed(() => {
if (props.loading && !isLeading.value) {
return props.loadingIcon
}
return props.trailingIcon || props.icon
})
const iconClass = computed(() => {
return classNames(
ui.value.icon.base,
ui.value.icon.size[props.size],
props.loading && 'animate-spin'
)
})
const leadingIconClass = computed(() => {
return classNames(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.spacing[props.size]
)
})
const trailingIconClass = computed(() => {
return classNames(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.spacing[props.size]
)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
isLeading,
isTrailing,
inputClass,
iconClass,
leadingIconName,
leadingIconClass,
trailingIconName,
trailingIconClass,
onInput
}
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
const input = ref<HTMLInputElement | null>(null)
const autoFocus = () => {
if (props.autofocus) {
input.value?.focus()
}
}
const onInput = (value: string) => {
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
</script>
<script lang="ts">
export default { name: 'UInput' }
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div :class="ui.wrapper">
<div v-if="label || $slots.label" :class="ui.labelWrapper">
<label :for="name" :class="ui.label">
<slot name="label">{{ label }}</slot>
<span v-if="required" :class="ui.required">*</span>
</label>
<span v-if="$slots.hint || hint" :class="ui.hint">
<slot name="hint">{{ hint }}</slot>
</span>
</div>
<p v-if="description" :class="ui.description">
{{ description }}
</p>
<div :class="!!label && ui.container">
<slot />
<p v-if="help" :class="ui.help">
{{ help }}
</p>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
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
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.inputGroup>>,
default: () => appConfig.ui.inputGroup
}
},
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.inputGroup>>(() => defu({}, props.ui, appConfig.ui.inputGroup))
return {
// eslint-disable-next-line vue/no-dupe-keys
ui
}
}
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="wrapperClass">
<div :class="ui.wrapper">
<div class="flex items-center h-5">
<input
:id="`${name}-${value}`"
@@ -9,102 +9,90 @@
:value="value"
:disabled="disabled"
type="radio"
:class="radioClass"
:class="[ui.base, ui.custom]"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
>
</div>
<div v-if="label || $slots.label" class="ml-3 text-sm">
<label :for="`${name}-${value}`" :class="labelClass">
<label :for="`${name}-${value}`" :class="ui.label">
<slot name="label">{{ label }}</slot>
<span v-if="required" :class="requiredClass">*</span>
<span v-if="required" :class="ui.required">*</span>
</label>
<p v-if="help" :class="helpClass">
<p v-if="help" :class="ui.help">
{{ help }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
const props = defineProps({
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
}
})
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
const isChecked = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const radioClass = computed(() => {
return classNames(
props.baseClass,
props.customClass
)
})
</script>
<script lang="ts">
export default { name: 'URadio' }
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
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
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.radio>>,
default: () => appConfig.ui.radio
}
},
emits: ['update:modelValue', 'focus', 'blur'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.radio>>(() => defu({}, props.ui, appConfig.ui.radio))
const isChecked = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
isChecked
}
}
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="wrapperClass">
<div :class="ui.wrapper">
<select
:id="name"
:name="name"
@@ -17,7 +17,7 @@
:label="option[textAttribute]"
>
<option
v-for="(childOption, index2) in option.children"
v-for="(childOption, index2) in (option.children as any[])"
:key="`${childOption[valueAttribute]}-${index}-${index2}`"
:value="childOption[valueAttribute]"
:selected="childOption[valueAttribute] === normalizedValue"
@@ -36,169 +36,197 @@
</template>
</select>
<div v-if="icon" :class="iconWrapperClass">
<div v-if="icon" :class="leadingIconClass">
<Icon :name="icon" :class="iconClass" />
</div>
<span :class="trailingIconClass">
<Icon name="i-heroicons-chevron-down-20-solid" :class="iconClass" aria-hidden="true" />
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { get } from 'lodash-es'
import { classNames } from '../../utils'
import Icon from '../elements/Icon.vue'
import $ui from '#build/ui'
const props = defineProps({
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: string) {
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
},
appearance: {
type: String,
default: 'default',
validator (value: string) {
return Object.keys($ui.select.appearance).includes(value)
}
},
textAttribute: {
type: String,
default: 'text'
},
valueAttribute: {
type: String,
default: 'value'
},
icon: {
type: String,
default: null
}
})
const emit = defineEmits(['update:modelValue'])
const onInput = (value: string) => {
emit('update:modelValue', value)
}
const guessOptionValue = (option: any) => {
return get(option, props.valueAttribute, get(option, props.textAttribute))
}
const guessOptionText = (option: any) => {
return get(option, props.textAttribute, get(option, props.valueAttribute))
}
const normalizeOption = (option: any) => {
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]: '',
[props.textAttribute]: props.placeholder,
disabled: true
},
...normalizedOptions.value
]
})
const normalizedValue = computed(() => {
const normalizeModelValue = normalizeOption(props.modelValue)
const foundOption = normalizedOptionsWithPlaceholder.value.find(option => option[props.valueAttribute] === normalizeModelValue[props.valueAttribute])
if (!foundOption) {
return ''
}
return foundOption[props.valueAttribute]
})
const selectClass = computed(() => {
return classNames(
props.baseClass,
$ui.select.size[props.size],
$ui.select.spacing[props.size],
$ui.select.appearance[props.appearance],
!!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.wrapper
</script>
<script lang="ts">
export default { name: 'USelect' }
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { get } from 'lodash-es'
import { defu } from 'defu'
import Icon from '../elements/Icon.vue'
import { classNames } from '../../utils'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
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
},
icon: {
type: String,
default: null
},
options: {
type: Array,
default: () => []
},
size: {
type: String,
default: () => appConfig.ui.select.default.size,
validator (value: string) {
return Object.keys(appConfig.ui.select.size).includes(value)
}
},
appearance: {
type: String,
default: () => appConfig.ui.select.default.appearance,
validator (value: string) {
return Object.keys(appConfig.ui.select.appearance).includes(value)
}
},
textAttribute: {
type: String,
default: 'text'
},
valueAttribute: {
type: String,
default: 'value'
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.select>>,
default: () => appConfig.ui.select
}
},
emits: ['update:modelValue', 'focus', 'blur'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.select>>(() => defu({}, props.ui, appConfig.ui.select))
const onInput = (value: string) => {
emit('update:modelValue', value)
}
const guessOptionValue = (option: any) => {
return get(option, props.valueAttribute, get(option, props.textAttribute))
}
const guessOptionText = (option: any) => {
return get(option, props.textAttribute, get(option, props.valueAttribute))
}
const normalizeOption = (option: any) => {
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]: '',
[props.textAttribute]: props.placeholder,
disabled: true
},
...normalizedOptions.value
]
})
const normalizedValue = computed(() => {
const normalizeModelValue = normalizeOption(props.modelValue)
const foundOption = normalizedOptionsWithPlaceholder.value.find(option => option[props.valueAttribute] === normalizeModelValue[props.valueAttribute])
if (!foundOption) {
return ''
}
return foundOption[props.valueAttribute]
})
const selectClass = computed(() => {
return classNames(
ui.value.base,
ui.value.size[props.size],
ui.value.spacing[props.size],
ui.value.appearance[props.appearance],
!!props.icon && ui.value.leading.spacing[props.size],
ui.value.trailing.spacing[props.size],
ui.value.custom
)
})
const iconClass = computed(() => {
return classNames(
ui.value.icon.base,
ui.value.icon.size[props.size]
)
})
const leadingIconClass = computed(() => {
return classNames(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.spacing[props.size]
)
})
const trailingIconClass = computed(() => {
return classNames(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.spacing[props.size]
)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
normalizedOptionsWithPlaceholder,
normalizedValue,
selectClass,
iconClass,
leadingIconClass,
trailingIconClass,
onInput
}
}
})
</script>

View File

@@ -1,346 +0,0 @@
<template>
<Combobox
v-slot="{ open }"
:by="by"
:model-value="modelValue"
:multiple="multiple"
:nullable="nullable"
:disabled="disabled"
as="div"
:class="wrapperClass"
@update:model-value="onUpdate"
>
<input :value="modelValue" :required="required" class="absolute inset-0 w-px opacity-0 cursor-default" tabindex="-1" aria-hidden="true">
<ComboboxButton ref="trigger" v-slot="{ disabled: buttonDisabled }" as="div" role="button" class="inline-flex w-full">
<slot :open="open" :disabled="buttonDisabled">
<button :class="selectCustomClass" :disabled="disabled" type="button">
<slot name="label">
<span v-if="modelValue" class="block truncate">{{ (modelValue as any)[textAttribute] }}</span>
<span v-else class="block truncate u-text-gray-400">{{ placeholder }}</span>
</slot>
<slot name="icon">
<span :class="iconWrapperClass">
<Icon v-if="icon" :name="icon" :class="iconClass" aria-hidden="true" />
</span>
</slot>
</button>
</slot>
</ComboboxButton>
<div v-if="open" ref="container" :class="[listContainerClass, listWidthClass]">
<transition appear v-bind="listTransitionClass">
<ComboboxOptions static :class="listBaseClass">
<ComboboxInput
v-if="searchable"
ref="searchInput"
:display-value="() => query"
name="q"
placeholder="Search..."
autofocus
autocomplete="off"
:class="listInputClass"
@change="query = $event.target.value"
/>
<ComboboxOption
v-for="(option, index) in filteredOptions"
v-slot="{ active, selected, disabled: optionDisabled }"
:key="index"
as="template"
:value="option"
:disabled="option.disabled"
>
<li :class="resolveOptionClass({ active, selected, disabled: optionDisabled })">
<div :class="listOptionContainerClass">
<slot name="option" :option="option" :active="active" :selected="selected">
<span class="block truncate">{{ option[textAttribute] }}</span>
</slot>
</div>
<span v-if="selected" :class="resolveOptionIconClass({ active })">
<Icon v-if="listOptionIcon" :name="listOptionIcon" :class="listOptionIconSizeClass" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption v-if="creatable && queryOption && !filteredOptions.length" v-slot="{ active, selected }" :value="queryOption" as="template">
<li :class="resolveOptionClass({ active, selected })">
<div :class="listOptionContainerClass">
<slot name="option-create" :option="queryOption" :active="active" :selected="selected">
<span class="block truncate">Create "{{ queryOption[textAttribute] }}"</span>
</slot>
</div>
</li>
</ComboboxOption>
<p v-else-if="searchable && query && !filteredOptions.length" :class="listOptionEmptyClass">
<slot name="option-empty" :query="query">
No results found for "{{ query }}".
</slot>
</p>
</ComboboxOptions>
</transition>
</div>
</Combobox>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { PropType, ComponentPublicInstance } from 'vue'
import { defu } from 'defu'
import {
Combobox,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
ComboboxInput
} from '@headlessui/vue'
import Icon from '../elements/Icon.vue'
import { classNames } from '../../utils'
import { usePopper } from '../../composables/usePopper'
import type { PopperOptions } from '../../types'
import $ui from '#build/ui'
const props = defineProps({
modelValue: {
type: [String, Number, Object, Array],
default: ''
},
by: {
type: String,
default: undefined
},
options: {
type: Array as PropType<{ [key: string]: any, disabled?: boolean }[] | string[]>,
default: () => []
},
required: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: false
},
nullable: {
type: Boolean,
default: false
},
searchable: {
type: Boolean,
default: false
},
creatable: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: 'Select an option'
},
size: {
type: String,
default: 'md',
validator (value: string) {
return Object.keys($ui.selectCustom.size).includes(value)
}
},
wrapperClass: {
type: String,
default: () => $ui.selectCustom.wrapper
},
baseClass: {
type: String,
default: () => $ui.selectCustom.base
},
icon: {
type: String,
default: () => $ui.selectCustom.icon.name
},
iconBaseClass: {
type: String,
default: () => $ui.selectCustom.icon.base
},
customClass: {
type: String,
default: null
},
appearance: {
type: String,
default: 'default',
validator (value: string) {
return Object.keys($ui.selectCustom.appearance).includes(value)
}
},
listBaseClass: {
type: String,
default: () => $ui.selectCustom.list.base
},
listContainerClass: {
type: String,
default: () => $ui.selectCustom.list.container
},
listWidthClass: {
type: String,
default: () => $ui.selectCustom.list.width
},
listInputClass: {
type: String,
default: () => $ui.selectCustom.list.input
},
listTransitionClass: {
type: Object,
default: () => $ui.selectCustom.list.transition
},
listOptionBaseClass: {
type: String,
default: () => $ui.selectCustom.list.option.base
},
listOptionContainerClass: {
type: String,
default: () => $ui.selectCustom.list.option.container
},
listOptionActiveClass: {
type: String,
default: () => $ui.selectCustom.list.option.active
},
listOptionInactiveClass: {
type: String,
default: () => $ui.selectCustom.list.option.inactive
},
listOptionSelectedClass: {
type: String,
default: () => $ui.selectCustom.list.option.selected
},
listOptionUnselectedClass: {
type: String,
default: () => $ui.selectCustom.list.option.unselected
},
listOptionDisabledClass: {
type: String,
default: () => $ui.selectCustom.list.option.disabled
},
listOptionEmptyClass: {
type: String,
default: () => $ui.selectCustom.list.option.empty
},
listOptionIcon: {
type: String,
default: () => $ui.selectCustom.list.option.icon.name
},
listOptionIconBaseClass: {
type: String,
default: () => $ui.selectCustom.list.option.icon.base
},
listOptionIconActiveClass: {
type: String,
default: () => $ui.selectCustom.list.option.icon.active
},
listOptionIconInactiveClass: {
type: String,
default: () => $ui.selectCustom.list.option.icon.inactive
},
listOptionIconSizeClass: {
type: String,
default: () => $ui.selectCustom.list.option.icon.size
},
textAttribute: {
type: String,
default: 'text'
},
searchAttributes: {
type: Array,
default: null
},
popperOptions: {
type: Object as PropType<PopperOptions>,
default: () => ({})
}
})
const emit = defineEmits(['update:modelValue', 'open', 'close'])
const popperOptions = computed<PopperOptions>(() => defu({}, props.popperOptions, $ui.selectCustom.popperOptions))
const [trigger, container] = usePopper(popperOptions.value)
const query = ref('')
const searchInput = ref<ComponentPublicInstance<HTMLElement>>()
const selectCustomClass = computed(() => {
return classNames(
props.baseClass,
$ui.selectCustom.size[props.size],
$ui.selectCustom.spacing[props.size],
$ui.selectCustom.appearance[props.appearance],
$ui.selectCustom.trailing.spacing[props.size],
props.customClass
)
})
const iconClass = computed(() => {
return classNames(
props.iconBaseClass,
$ui.selectCustom.icon.size[props.size],
'mr-2'
)
})
const filteredOptions = computed(() =>
query.value === ''
? props.options
: (props.options as any[]).filter((option: any) => {
return (props.searchAttributes?.length ? props.searchAttributes : [props.textAttribute]).some((searchAttribute: any) => {
return typeof option === 'string' ? option.search(new RegExp(query.value, 'i')) !== -1 : (option[searchAttribute] && option[searchAttribute].search(new RegExp(query.value, 'i')) !== -1)
})
})
)
const queryOption = computed(() => {
return query.value === '' ? null : { [props.textAttribute]: query.value }
})
const iconWrapperClass = classNames(
$ui.selectCustom.icon.trailing.wrapper
)
watch(container, (value) => {
if (value) {
emit('open')
} else {
emit('close')
}
})
function resolveOptionClass ({ active, selected, disabled }: { active: boolean, selected: boolean, disabled?: boolean }) {
return classNames(
props.listOptionBaseClass,
active ? props.listOptionActiveClass : props.listOptionInactiveClass,
selected ? props.listOptionSelectedClass : props.listOptionUnselectedClass,
disabled && props.listOptionDisabledClass
)
}
function resolveOptionIconClass ({ active }: { active: boolean }) {
return classNames(
props.listOptionIconBaseClass,
active ? props.listOptionIconActiveClass : props.listOptionIconInactiveClass
)
}
function onUpdate (event: any) {
if (query.value && searchInput.value?.$el) {
query.value = ''
// explicitly set input text because `ComboboxInput` `displayValue` is not reactive
searchInput.value.$el.value = ''
}
emit('update:modelValue', event)
}
</script>
<script lang="ts">
export default { name: 'USelectCustom' }
</script>

View File

@@ -0,0 +1,329 @@
<template>
<component
:is="searchable ? 'Combobox' : 'Listbox'"
v-slot="{ open }"
:by="by"
:name="name"
:model-value="modelValue"
:multiple="multiple"
:disabled="disabled"
as="div"
:class="ui.wrapper"
@update:model-value="onUpdate"
>
<!-- TODO: check that `name` fixes required -->
<!-- <input :value="modelValue" :required="required" class="absolute inset-0 w-px opacity-0 cursor-default" tabindex="-1" aria-hidden="true"> -->
<component
:is="searchable ? 'ComboboxButton' : 'ListboxButton'"
ref="trigger"
as="div"
role="button"
class="inline-flex w-full"
>
<slot :open="open" :disabled="disabled">
<button :class="selectMenuClass" :disabled="disabled" type="button">
<span v-if="icon" :class="leadingIconClass">
<Icon :name="icon" :class="iconClass" />
</span>
<slot name="label">
<span v-if="modelValue" class="block truncate">{{ typeof modelValue === 'string' ? modelValue : (modelValue as any)[optionAttribute] }}</span>
<span v-else class="block truncate text-gray-400 dark:text-gray-500">{{ placeholder || '&nbsp;' }}</span>
</slot>
<span :class="trailingIconClass">
<Icon name="i-heroicons-chevron-down-20-solid" :class="iconClass" aria-hidden="true" />
</span>
</button>
</slot>
</component>
<div v-if="open" ref="container" :class="[ui.container, ui.width]">
<transition v-bind="ui.transition">
<component :is="searchable ? 'ComboboxOptions' : 'ListboxOptions'" static :class="[ui.base, ui.divide, ui.ring, ui.rounded, ui.shadow, ui.background, ui.spacing, ui.height]">
<ComboboxInput
v-if="searchable"
ref="searchInput"
:display-value="() => query"
name="q"
placeholder="Search..."
autofocus
autocomplete="off"
:class="ui.input"
@change="query = $event.target.value"
/>
<component
:is="searchable ? 'ComboboxOption' : 'ListboxOption'"
v-for="(option, index) in filteredOptions"
v-slot="{ active, selected, disabled: optionDisabled }"
:key="index"
as="template"
:value="option"
:disabled="option.disabled"
>
<li :class="resolveOptionClass({ active, disabled: optionDisabled })">
<div :class="ui.option.container">
<slot name="option" :option="option" :active="active" :selected="selected">
<Icon v-if="option.icon" :name="option.icon" :class="[ui.option.icon.base, active ? ui.option.icon.active : ui.option.icon.inactive, option.iconClass]" aria-hidden="true" />
<Avatar
v-else-if="option.avatar"
v-bind="{ size: ui.option.avatar.size, ...option.avatar }"
:class="ui.option.avatar.base"
aria-hidden="true"
/>
<span v-else-if="option.chip" :class="ui.option.chip.base" :style="{ background: `#${option.chip}` }" />
<span class="truncate">{{ typeof option === 'string' ? option : option[optionAttribute] }}</span>
</slot>
</div>
<span v-if="selected" :class="ui.option.selected.wrapper">
<Icon :name="selectedIcon" :class="ui.option.selected.icon" aria-hidden="true" />
</span>
</li>
</component>
<component :is="searchable ? 'ComboboxOption' : 'ListboxOption'" v-if="creatable && queryOption && !filteredOptions.length" v-slot="{ active, selected }" :value="queryOption" as="template">
<li :class="resolveOptionClass({ active })">
<div :class="ui.option.container">
<slot name="option-create" :option="queryOption" :active="active" :selected="selected">
<span class="block truncate">Create "{{ queryOption[optionAttribute] }}"</span>
</slot>
</div>
</li>
</component>
<p v-else-if="searchable && query && !filteredOptions.length" :class="ui.option.empty">
<slot name="option-empty" :query="query">
No results found for "{{ query }}".
</slot>
</p>
</component>
</transition>
</div>
</component>
</template>
<script lang="ts">
import { ref, computed, watch, defineComponent } from 'vue'
import type { PropType, ComponentPublicInstance } from 'vue'
import { defu } from 'defu'
import { Combobox, ComboboxButton, ComboboxOptions, ComboboxOption, ComboboxInput, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/vue'
import Icon from '../elements/Icon.vue'
import Avatar from '../elements/Avatar.vue'
import { classNames } from '../../utils'
import { usePopper } from '../../composables/usePopper'
import type { PopperOptions } from '../../types'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
components: {
Combobox,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
ComboboxInput,
Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
Icon,
Avatar
},
props: {
modelValue: {
type: [String, Number, Object, Array],
default: ''
},
by: {
type: String,
default: undefined
},
options: {
type: Array as PropType<{ [key: string]: any, disabled?: boolean }[] | string[]>,
default: () => []
},
name: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
icon: {
type: String,
default: null
},
selectedIcon: {
type: String,
default: () => appConfig.ui.selectMenu.default.selectedIcon
},
disabled: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: false
},
searchable: {
type: Boolean,
default: false
},
creatable: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: null
},
size: {
type: String,
default: () => appConfig.ui.select.default.size,
validator (value: string) {
return Object.keys(appConfig.ui.select.size).includes(value)
}
},
appearance: {
type: String,
default: () => appConfig.ui.select.default.appearance,
validator (value: string) {
return Object.keys(appConfig.ui.select.appearance).includes(value)
}
},
optionAttribute: {
type: String,
default: 'label'
},
searchAttributes: {
type: Array,
default: null
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.selectMenu>>,
default: () => appConfig.ui.selectMenu
},
uiSelect: {
type: Object as PropType<Partial<typeof appConfig.ui.select>>,
default: () => appConfig.ui.select
}
},
emits: ['update:modelValue', 'open', 'close'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.selectMenu>>(() => defu({}, props.ui, appConfig.ui.selectMenu))
const uiSelect = computed<Partial<typeof appConfig.ui.select>>(() => defu({}, props.uiSelect, appConfig.ui.select))
const popper = computed<PopperOptions>(() => defu({}, props.popper, ui.value.popper as PopperOptions))
const [trigger, container] = usePopper(popper.value)
const query = ref('')
const searchInput = ref<ComponentPublicInstance<HTMLElement>>()
const selectMenuClass = computed(() => {
return classNames(
uiSelect.value.base,
'text-left cursor-default',
uiSelect.value.size[props.size],
uiSelect.value.gap[props.size],
uiSelect.value.spacing[props.size],
uiSelect.value.appearance[props.appearance],
!!props.icon && uiSelect.value.leading.spacing[props.size],
uiSelect.value.trailing.spacing[props.size],
uiSelect.value.custom,
'inline-flex items-center'
)
})
const iconClass = computed(() => {
return classNames(
uiSelect.value.icon.base,
uiSelect.value.icon.size[props.size]
)
})
const leadingIconClass = computed(() => {
return classNames(
uiSelect.value.icon.leading.wrapper,
uiSelect.value.icon.leading.spacing[props.size]
)
})
const trailingIconClass = computed(() => {
return classNames(
uiSelect.value.icon.trailing.wrapper,
uiSelect.value.icon.trailing.spacing[props.size]
)
})
const filteredOptions = computed(() =>
query.value === ''
? props.options
: (props.options as any[]).filter((option: any) => {
return (props.searchAttributes?.length ? props.searchAttributes : [props.optionAttribute]).some((searchAttribute: any) => {
return typeof option === 'string' ? option.search(new RegExp(query.value, 'i')) !== -1 : (option[searchAttribute] && option[searchAttribute].search(new RegExp(query.value, 'i')) !== -1)
})
})
)
const queryOption = computed(() => {
return query.value === '' ? null : { [props.optionAttribute]: query.value }
})
watch(container, (value) => {
if (value) {
emit('open')
} else {
emit('close')
}
})
function resolveOptionClass ({ active, disabled }: { active: boolean, disabled?: boolean }) {
return classNames(
ui.value.option.base,
active ? ui.value.option.active : ui.value.option.inactive,
disabled && ui.value.option.disabled
)
}
function onUpdate (event: any) {
if (query.value && searchInput.value?.$el) {
query.value = ''
// explicitly set input text because `ComboboxInput` `displayValue` is not reactive
searchInput.value.$el.value = ''
}
emit('update:modelValue', event)
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
trigger,
container,
selectMenuClass,
iconClass,
leadingIconClass,
trailingIconClass,
filteredOptions,
queryOption,
query,
resolveOptionClass,
onUpdate
}
}
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="wrapperClass">
<div :class="ui.wrapper">
<textarea
:id="name"
ref="textarea"
@@ -18,141 +18,150 @@
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
const props = defineProps({
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: string) {
return Object.keys($ui.textarea.appearance).includes(value)
}
},
resize: {
type: Boolean,
default: true
},
size: {
type: String,
default: 'md',
validator (value: string) {
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
}
})
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
const textarea = ref<HTMLTextAreaElement | null>(null)
const autoFocus = () => {
if (props.autofocus) {
textarea.value?.focus()
}
}
const autoResize = () => {
if (props.autoresize) {
if (!textarea.value) {
return
}
textarea.value.rows = props.rows
const styles = window.getComputedStyle(textarea.value)
const paddingTop = parseInt(styles.paddingTop)
const paddingBottom = parseInt(styles.paddingBottom)
const padding = paddingTop + paddingBottom
const lineHeight = parseInt(styles.lineHeight)
const { scrollHeight } = textarea.value
const newRows = (scrollHeight - padding) / lineHeight
if (newRows > props.rows) {
textarea.value.rows = newRows
}
}
}
const onInput = (value: string) => {
autoResize()
emit('update:modelValue', value)
}
watch(() => props.modelValue, () => {
nextTick(autoResize)
})
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
)
})
</script>
<script lang="ts">
export default { name: 'UTextarea' }
import { ref, computed, watch, onMounted, nextTick, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { classNames } from '../../utils'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
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
},
resize: {
type: Boolean,
default: false
},
size: {
type: String,
default: () => appConfig.ui.textarea.default.size,
validator (value: string) {
return Object.keys(appConfig.ui.textarea.size).includes(value)
}
},
appearance: {
type: String,
default: () => appConfig.ui.textarea.default.appearance,
validator (value: string) {
return Object.keys(appConfig.ui.textarea.appearance).includes(value)
}
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.textarea>>,
default: () => appConfig.ui.textarea
}
},
emits: ['update:modelValue', 'focus', 'blur'],
setup (props, { emit }) {
const textarea = ref<HTMLTextAreaElement | null>(null)
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.textarea>>(() => defu({}, props.ui, appConfig.ui.textarea))
const autoFocus = () => {
if (props.autofocus) {
textarea.value?.focus()
}
}
const autoResize = () => {
if (props.autoresize) {
if (!textarea.value) {
return
}
textarea.value.rows = props.rows
const styles = window.getComputedStyle(textarea.value)
const paddingTop = parseInt(styles.paddingTop)
const paddingBottom = parseInt(styles.paddingBottom)
const padding = paddingTop + paddingBottom
const lineHeight = parseInt(styles.lineHeight)
const { scrollHeight } = textarea.value
const newRows = (scrollHeight - padding) / lineHeight
if (newRows > props.rows) {
textarea.value.rows = newRows
}
}
}
const onInput = (value: string) => {
autoResize()
emit('update:modelValue', value)
}
watch(() => props.modelValue, () => {
nextTick(autoResize)
})
onMounted(() => {
setTimeout(() => {
autoFocus()
autoResize()
}, 100)
})
const textareaClass = computed(() => {
return classNames(
ui.value.base,
ui.value.size[props.size],
ui.value.spacing[props.size],
ui.value.appearance[props.appearance],
!props.resize && 'resize-none',
ui.value.custom
)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
textareaClass,
onInput
}
}
})
</script>

View File

@@ -1,96 +1,77 @@
<template>
<Switch
v-model="active"
:class="[active ? activeClass : inactiveClass, baseClass]"
:class="[active ? ui.active : ui.inactive, ui.base]"
>
<span :class="[active ? containerActiveClass : containerInactiveClass, containerBaseClass]">
<span v-if="iconOn" :class="[active ? iconActiveClass : iconInactiveClass, iconBaseClass]" aria-hidden="true">
<Icon :name="iconOn" :class="iconOnClass" />
<span :class="[active ? ui.container.active : ui.container.inactive, ui.container.base]">
<span v-if="iconOn" :class="[active ? ui.icon.active : ui.icon.inactive, ui.icon.base]" aria-hidden="true">
<Icon :name="iconOn" :class="ui.icon.on" />
</span>
<span v-if="iconOff" :class="[active ? iconInactiveClass : iconActiveClass, iconBaseClass]" aria-hidden="true">
<Icon :name="iconOff" :class="iconOffClass" />
<span v-if="iconOff" :class="[active ? ui.icon.active : ui.icon.inactive, ui.icon.base]" aria-hidden="true">
<Icon :name="iconOff" :class="ui.icon.off" />
</span>
</span>
</Switch>
</template>
<script setup lang="ts">
import { computed } from 'vue'
<script lang="ts">
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { Switch } from '@headlessui/vue'
import Icon from '../elements/Icon.vue'
import $ui from '#build/ui'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
iconOn: {
type: String,
default: null
},
iconOff: {
type: String,
default: null
},
baseClass: {
type: String,
default: () => $ui.toggle.base
},
activeClass: {
type: String,
default: () => $ui.toggle.active
},
inactiveClass: {
type: String,
default: () => $ui.toggle.inactive
},
containerBaseClass: {
type: String,
default: () => $ui.toggle.container.base
},
containerActiveClass: {
type: String,
default: () => $ui.toggle.container.active
},
containerInactiveClass: {
type: String,
default: () => $ui.toggle.container.inactive
},
iconBaseClass: {
type: String,
default: () => $ui.toggle.icon.base
},
iconActiveClass: {
type: String,
default: () => $ui.toggle.icon.active
},
iconInactiveClass: {
type: String,
default: () => $ui.toggle.icon.inactive
},
iconOnClass: {
type: String,
default: () => $ui.toggle.icon.on
},
iconOffClass: {
type: String,
default: () => $ui.toggle.icon.off
}
})
// const appConfig = useAppConfig()
const emit = defineEmits(['update:modelValue'])
const active = computed({
get () {
return props.modelValue
export default defineComponent({
components: {
// eslint-disable-next-line vue/no-reserved-component-names
Switch,
Icon
},
set (value) {
emit('update:modelValue', value)
props: {
modelValue: {
type: Boolean,
default: false
},
iconOn: {
type: String,
default: null
},
iconOff: {
type: String,
default: null
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.toggle>>,
default: () => appConfig.ui.toggle
}
},
emits: ['update:modelValue'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.toggle>>(() => defu({}, props.ui, appConfig.ui.toggle))
const active = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
active
}
}
})
</script>
<script lang="ts">
export default { name: 'UToggle' }
</script>

View File

@@ -1,114 +1,50 @@
<template>
<component
:is="$attrs.onSubmit ? 'form': 'div'"
:class="cardClass"
:class="[ui.base, ui.rounded, ui.divide, ui.ring, ui.shadow, ui.background]"
v-bind="$attrs"
>
<div
v-if="$slots.header"
:class="[headerClass, headerBackgroundClass, borderColorClass, !!$slots.default && 'border-b']"
>
<div v-if="$slots.header" :class="[ui.header.base, ui.header.spacing, ui.header.background]">
<slot name="header" />
</div>
<div :class="[bodyClass, bodyBackgroundClass]">
<div :class="[ui.body.base, ui.body.spacing, ui.body.background]">
<slot />
</div>
<div
v-if="$slots.footer"
:class="[footerClass, footerBackgroundClass, borderColorClass, (!!$slots.default || (!$slots.default && !!$slots.header)) && 'border-t']"
>
<div v-if="$slots.footer" :class="[ui.footer.base, ui.footer.spacing, ui.footer.background]">
<slot name="footer" />
</div>
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
<script lang="ts">
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
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
},
roundedClass: {
type: String,
default: () => $ui.card.rounded,
validator (value: string) {
return !value || ['sm', 'md', 'lg', 'xl', '2xl', '3xl'].map(size => `rounded-${size}`).includes(value)
// const appConfig = useAppConfig()
export default defineComponent({
inheritAttrs: false,
props: {
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.card>>,
default: () => appConfig.ui.card
}
},
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) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.card>>(() => defu({}, props.ui, appConfig.ui.card))
return {
// eslint-disable-next-line vue/no-dupe-keys
ui
}
}
})
const cardClass = computed(() => {
return classNames(
props.baseClass,
props.padded && props.rounded && props.roundedClass,
!props.padded && props.rounded && props.roundedClass && `sm:${props.roundedClass}`,
props.ringClass,
props.shadowClass,
props.backgroundClass,
props.customClass
)
})
</script>
<script lang="ts">
export default {
name: 'UCard',
inheritAttrs: false
}
</script>

View File

@@ -1,38 +1,37 @@
<template>
<div :class="containerClass">
<div :class="[ui.base, ui.spacing, ui.constrained]">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
<script lang="ts">
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
padded: {
type: Boolean,
default: false
// const appConfig = useAppConfig()
export default defineComponent({
props: {
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.container>>,
default: () => appConfig.ui.container
}
},
constrained: {
type: Boolean,
default: true
},
constrainedClass: {
type: String,
default: () => $ui.container.constrained
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.container>>(() => defu({}, props.ui, appConfig.ui.container))
return {
// eslint-disable-next-line vue/no-dupe-keys
ui
}
}
})
const containerClass = computed(() => {
return classNames(
'mx-auto sm:px-6 lg:px-8',
props.padded && 'px-4',
props.constrained && props.constrainedClass
)
})
</script>
<script lang="ts">
export default { name: 'UContainer' }
</script>

View File

@@ -7,24 +7,22 @@
:nullable="nullable"
@update:model-value="onSelect"
>
<div :class="$ui.commandPalette.wrapper">
<div v-show="searchable" class="relative flex items-center">
<Icon v-if="inputIcon" :name="inputIcon" :class="$ui.commandPalette.input.icon.base" aria-hidden="true" />
<div :class="ui.wrapper">
<div v-if="searchable" :class="ui.input.wrapper">
<Icon v-if="icon" :name="icon" :class="ui.input.icon" aria-hidden="true" />
<ComboboxInput
ref="comboboxInput"
:value="query"
:class="$ui.commandPalette.input.base"
:placeholder="inputPlaceholder"
:class="[ui.input.base, icon && ui.input.spacing]"
:placeholder="placeholder"
autocomplete="off"
@change="query = $event.target.value"
/>
<Button
v-if="inputCloseIcon"
:icon="inputCloseIcon"
:class="$ui.commandPalette.input.close.base"
:size="$ui.commandPalette.input.close.size"
:variant="$ui.commandPalette.input.close.variant"
v-if="close"
v-bind="close"
:class="ui.input.close"
aria-label="Close"
@click="onClear"
/>
@@ -36,7 +34,7 @@
hold
as="div"
aria-label="Commands"
class="relative flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800 scroll-py-2"
:class="ui.container"
>
<CommandPaletteGroup
v-for="group of groups"
@@ -45,6 +43,8 @@
:group="group"
:group-attribute="groupAttribute"
:command-attribute="commandAttribute"
:selected-icon="selectedIcon"
:ui="ui"
>
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
@@ -52,18 +52,18 @@
</CommandPaletteGroup>
</ComboboxOptions>
<div v-else-if="placeholder" class="flex flex-col items-center justify-center flex-1 px-6 py-14 sm:px-14">
<Icon v-if="emptyIcon" :name="emptyIcon" class="w-6 h-6 mx-auto u-text-gray-400 mb-4" aria-hidden="true" />
<p class="text-sm text-center u-text-gray-900">
{{ query ? "We couldn't find any items with that term. Please try again." : "We couldn't find any items." }}
<div v-else-if="empty" :class="ui.empty.wrapper">
<Icon v-if="empty.icon" :name="empty.icon" :class="ui.empty.icon" aria-hidden="true" />
<p :class="query ? ui.empty.queryLabel : ui.empty.label">
{{ query ? empty.queryLabel : empty.label }}
</p>
</div>
</div>
</Combobox>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
<script lang="ts">
import { ref, computed, watch, onMounted, defineComponent } from 'vue'
import { Combobox, ComboboxInput, ComboboxOptions } from '@headlessui/vue'
import type { ComputedRef, PropType, ComponentPublicInstance } from 'vue'
import { useDebounceFn } from '@vueuse/core'
@@ -74,196 +74,228 @@ import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { Group, Command } from '../../types/command-palette'
import Icon from '../elements/Icon.vue'
import Button from '../elements/Button.vue'
import type { Button as ButtonType } from '../../types/button'
import CommandPaletteGroup from './CommandPaletteGroup.vue'
import $ui from '#build/ui'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
modelValue: {
type: [String, Number, Object, Array],
default: null
},
by: {
type: String,
default: 'id'
},
multiple: {
type: Boolean,
default: false
},
nullable: {
type: Boolean,
default: false
},
searchable: {
type: Boolean,
default: true
},
groups: {
type: Array as PropType<Group[]>,
default: () => []
},
inputIcon: {
type: String,
default: () => $ui.commandPalette.input.icon.name
},
inputCloseIcon: {
type: String,
default: () => $ui.commandPalette.input.close.icon.name
},
inputPlaceholder: {
type: String,
default: 'Search...'
},
emptyIcon: {
type: String,
default: () => $ui.commandPalette.empty.icon.name
},
groupAttribute: {
type: String,
default: 'label'
},
commandAttribute: {
type: String,
default: 'label'
},
options: {
type: Object as PropType<Partial<UseFuseOptions<Command>>>,
default: () => ({})
},
autoselect: {
type: Boolean,
default: true
},
autoclear: {
type: Boolean,
default: true
},
placeholder: {
type: Boolean,
default: true
},
debounce: {
type: Number,
default: 200
}
})
// const appConfig = useAppConfig()
const emit = defineEmits(['update:modelValue', 'close'])
const query = ref('')
const comboboxInput = ref<ComponentPublicInstance<HTMLInputElement>>()
const comboboxApi = ref(null)
onMounted(() => {
if (props.autoselect) {
activateFirstOption()
}
})
onMounted(() => {
setTimeout(() => {
// @ts-expect-error internals
const popoverProvides = comboboxInput.value?.$.provides
if (!popoverProvides) {
return
export default defineComponent({
components: {
Combobox,
ComboboxInput,
ComboboxOptions,
Icon,
// eslint-disable-next-line vue/no-reserved-component-names
Button,
CommandPaletteGroup
},
props: {
modelValue: {
type: [String, Number, Object, Array],
default: null
},
by: {
type: String,
default: 'id'
},
multiple: {
type: Boolean,
default: false
},
nullable: {
type: Boolean,
default: false
},
searchable: {
type: Boolean,
default: true
},
groups: {
type: Array as PropType<Group[]>,
default: () => []
},
icon: {
type: String,
default: () => appConfig.ui.commandPalette.default.icon
},
selectedIcon: {
type: String,
default: () => appConfig.ui.commandPalette.default.selectedIcon
},
close: {
type: Object as PropType<Partial<ButtonType>>,
default: () => appConfig.ui.commandPalette.default.close
},
empty: {
type: Object as PropType<{ icon: string, label: string, queryLabel: string }>,
default: () => appConfig.ui.commandPalette.default.empty
},
placeholder: {
type: String,
default: 'Search...'
},
groupAttribute: {
type: String,
default: 'label'
},
commandAttribute: {
type: String,
default: 'label'
},
autoselect: {
type: Boolean,
default: true
},
autoclear: {
type: Boolean,
default: true
},
debounce: {
type: Number,
default: 200
},
fuse: {
type: Object as PropType<Partial<UseFuseOptions<Command>>>,
default: () => ({})
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.commandPalette>>,
default: () => appConfig.ui.commandPalette
}
const popoverProvidesSymbols = Object.getOwnPropertySymbols(popoverProvides)
comboboxApi.value = popoverProvidesSymbols.length && popoverProvides[popoverProvidesSymbols[0]]
}, 200)
})
const options: ComputedRef<Partial<UseFuseOptions<Command>>> = computed(() => defu({}, props.options, {
fuseOptions: {
keys: [props.commandAttribute]
},
resultLimit: 12,
matchAllWhenSearchEmpty: true
}))
emits: ['update:modelValue', 'close'],
setup (props, { emit, expose }) {
// TODO: Remove
const appConfig = useAppConfig()
const commands = computed(() => props.groups.filter(group => !group.search).reduce((acc, group) => {
return acc.concat(group.commands.map(command => ({ ...command, group: group.key })))
}, [] as Command[]))
const ui = computed<Partial<typeof appConfig.ui.commandPalette>>(() => defu({}, props.ui, appConfig.ui.commandPalette))
const searchResults = ref<{ [key: string]: any }>({})
const query = ref('')
const comboboxInput = ref<ComponentPublicInstance<HTMLInputElement>>()
const comboboxApi = ref(null)
const { results } = useFuse(query, commands, options)
const groups = computed(() => ([
...map(groupBy(results.value, command => command.item.group), (results, key) => {
const commands = results.map((result) => {
const { item, ...data } = result
return {
...item,
...data
onMounted(() => {
if (props.autoselect) {
activateFirstOption()
}
})
onMounted(() => {
setTimeout(() => {
// @ts-expect-error internals
const popoverProvides = comboboxInput.value?.$.provides
if (!popoverProvides) {
return
}
const popoverProvidesSymbols = Object.getOwnPropertySymbols(popoverProvides)
comboboxApi.value = popoverProvidesSymbols.length && popoverProvides[popoverProvidesSymbols[0]]
}, 200)
})
const options: ComputedRef<Partial<UseFuseOptions<Command>>> = computed(() => defu({}, props.fuse, {
fuseOptions: {
keys: [props.commandAttribute]
},
resultLimit: 12,
matchAllWhenSearchEmpty: true
}))
const commands = computed(() => props.groups.filter(group => !group.search).reduce((acc, group) => {
return acc.concat(group.commands.map(command => ({ ...command, group: group.key })))
}, [] as Command[]))
const searchResults = ref<{ [key: string]: any }>({})
const { results } = useFuse(query, commands, options)
const groups = computed(() => ([
...map(groupBy(results.value, command => command.item.group), (results, key) => {
const commands = results.map((result) => {
const { item, ...data } = result
return {
...item,
...data
}
})
return {
...props.groups.find(group => group.key === key),
commands: commands.slice(0, options.value.resultLimit)
} as Group
}),
...props.groups.filter(group => !!group.search).map(group => ({ ...group, commands: (searchResults.value[group.key] || []).slice(0, options.value.resultLimit) })).filter(group => group.commands.length)
]))
const debouncedSearch = useDebounceFn(async () => {
const searchableGroups = props.groups.filter(group => !!group.search)
await Promise.all(searchableGroups.map(async (group) => {
searchResults.value[group.key] = await group.search(query.value)
}))
}, props.debounce)
watch(query, () => {
debouncedSearch()
// Select first item on search changes
setTimeout(() => {
// https://github.com/tailwindlabs/headlessui/blob/6fa6074cd5d3a96f78a2d965392aa44101f5eede/packages/%40headlessui-vue/src/components/combobox/combobox.ts#L804
comboboxInput.value?.$el.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' }))
}, 0)
})
// Methods
function activateFirstOption () {
// hack combobox by using keyboard event
// https://github.com/tailwindlabs/headlessui/blob/6fa6074cd5d3a96f78a2d965392aa44101f5eede/packages/%40headlessui-vue/src/components/combobox/combobox.ts#L769
setTimeout(() => {
comboboxInput.value?.$el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
}, 0)
}
function onSelect (option: Command | Command[]) {
emit('update:modelValue', option, { query: query.value })
// Clear input after selection
if (props.autoclear) {
setTimeout(() => {
query.value = ''
}, 0)
}
}
function onClear () {
if (query.value) {
query.value = ''
} else {
emit('close')
}
}
expose({
query,
updateQuery: (q: string) => {
query.value = q
},
comboboxApi,
results
})
return {
...props.groups.find(group => group.key === key),
commands: commands.slice(0, options.value.resultLimit)
} as Group
}),
...props.groups.filter(group => !!group.search).map(group => ({ ...group, commands: (searchResults.value[group.key] || []).slice(0, options.value.resultLimit) })).filter(group => group.commands.length)
]))
const debouncedSearch = useDebounceFn(async () => {
const searchableGroups = props.groups.filter(group => !!group.search)
await Promise.all(searchableGroups.map(async (group) => {
searchResults.value[group.key] = await group.search(query.value)
}))
}, props.debounce)
watch(query, () => {
debouncedSearch()
// Select first item on search changes
setTimeout(() => {
// https://github.com/tailwindlabs/headlessui/blob/6fa6074cd5d3a96f78a2d965392aa44101f5eede/packages/%40headlessui-vue/src/components/combobox/combobox.ts#L804
comboboxInput.value?.$el.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' }))
}, 0)
})
// Methods
function activateFirstOption () {
// hack combobox by using keyboard event
// https://github.com/tailwindlabs/headlessui/blob/6fa6074cd5d3a96f78a2d965392aa44101f5eede/packages/%40headlessui-vue/src/components/combobox/combobox.ts#L769
setTimeout(() => {
comboboxInput.value?.$el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
}, 0)
}
function onSelect (option: Command | Command[]) {
emit('update:modelValue', option, { query: query.value })
// Clear input after selection
if (props.autoclear) {
setTimeout(() => {
query.value = ''
}, 0)
// eslint-disable-next-line vue/no-dupe-keys
ui,
// eslint-disable-next-line vue/no-dupe-keys
groups,
query,
onSelect,
onClear
}
}
}
function onClear () {
if (query.value) {
query.value = ''
} else {
emit('close')
}
}
defineExpose({
query,
updateQuery: (q: string) => {
query.value = q
},
comboboxApi,
results
})
</script>
<script lang="ts">
export default { name: 'UCommandPalette' }
</script>

View File

@@ -1,10 +1,10 @@
<template>
<div class="p-2" role="option">
<h2 v-if="label" class="px-3 my-2 text-xs font-semibold u-text-gray-900">
<div :class="ui.group.wrapper" role="option">
<h2 v-if="label" :class="ui.group.label">
{{ label }}
</h2>
<div class="text-sm u-text-gray-700" role="listbox" :aria-label="group[groupAttribute]">
<div :class="ui.group.container" role="listbox" :aria-label="group[groupAttribute]">
<ComboboxOption
v-for="(command, index) of group.commands"
:key="`${group.key}-${index}`"
@@ -13,41 +13,41 @@
:disabled="command.disabled"
as="template"
>
<div :class="['flex justify-between select-none items-center rounded-md px-3 py-2 gap-3 relative', active && 'bg-gray-100 dark:bg-gray-800 u-text-gray-900', command.disabled ? 'cursor-not-allowed' : 'cursor-pointer']">
<div class="flex items-center gap-2 min-w-0">
<div :class="[ui.group.command.base, active ? ui.group.command.active : ui.group.command.inactive, command.disabled ? 'cursor-not-allowed' : 'cursor-pointer']">
<div :class="ui.group.command.container">
<slot :name="`${group.key}-icon`" :group="group" :command="command">
<Icon v-if="command.icon" :name="command.icon" :class="['h-4 w-4 flex-shrink-0', active ? 'text-opacity-100 dark:text-opacity-100' : 'text-opacity-40 dark:text-opacity-40', command.iconClass || 'text-gray-900 dark:text-gray-50']" aria-hidden="true" />
<Icon v-if="command.icon" :name="command.icon" :class="[ui.group.command.icon.base, active ? ui.group.command.icon.active : ui.group.command.icon.inactive, command.iconClass]" aria-hidden="true" />
<Avatar
v-else-if="command.avatar"
v-bind="{ size: 'xxxs', ...command.avatar }"
class="flex-shrink-0"
v-bind="{ size: ui.group.command.avatar.size, ...command.avatar }"
:class="ui.group.command.avatar.base"
aria-hidden="true"
/>
<span v-else-if="command.chip" class="flex-shrink-0 w-2 h-2 mx-1 rounded-full" :style="{ background: `#${command.chip}` }" />
<span v-else-if="command.chip" :class="ui.group.command.chip.base" :style="{ background: `#${command.chip}` }" />
</slot>
<div class="flex items-center gap-1.5 min-w-0" :class="{ 'opacity-50': command.disabled }">
<div :class="[ui.group.command.label, command.disabled && ui.group.command.disabled]">
<slot :name="`${group.key}-command`" :group="group" :command="command">
<span v-if="command.prefix" class="flex-shrink-0" :class="command.prefixClass || 'u-text-gray-400'">{{ command.prefix }}</span>
<span v-if="command.prefix" class="flex-shrink-0" :class="command.prefixClass || ui.group.command.prefix">{{ command.prefix }}</span>
<span class="truncate" :class="{ 'flex-none': command.suffix || command.matches?.length }">{{ command[commandAttribute] }}</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="command.matches?.length" class="truncate" :class="command.suffixClass || 'u-text-gray-400'" v-html="highlight(command[commandAttribute], command.matches[0])" />
<span v-else-if="command.suffix" class="truncate" :class="command.suffixClass || 'u-text-gray-400'">{{ command.suffix }}</span>
<span v-if="command.matches?.length" class="truncate" :class="command.suffixClass || ui.group.command.suffix" v-html="highlight(command[commandAttribute], command.matches[0])" />
<span v-else-if="command.suffix" class="truncate" :class="command.suffixClass || ui.group.command.suffix">{{ command.suffix }}</span>
</slot>
</div>
</div>
<Icon v-if="selected" :name="$ui.commandPalette.option.selected.icon.name" class="h-5 w-5 u-text-gray-900 flex-shrink-0" aria-hidden="true" />
<Icon v-if="selected" :name="selectedIcon" :class="ui.group.command.selected.icon" aria-hidden="true" />
<slot v-else-if="active && (group.active || $slots[`${group.key}-active`])" :name="`${group.key}-active`" :group="group" :command="command">
<span v-if="group.active" class="flex-shrink-0 u-text-gray-500">{{ group.active }}</span>
<span v-if="group.active" :class="ui.group.active">{{ group.active }}</span>
</slot>
<slot v-else :name="`${group.key}-inactive`" :group="group" :command="command">
<span v-if="command.shortcuts?.length" :class="$ui.commandPalette.option.shortcuts">
<span v-if="command.shortcuts?.length" :class="ui.group.command.shortcuts">
<kbd v-for="shortcut of command.shortcuts" :key="shortcut" class="font-sans">{{ shortcut }}</kbd>
</span>
<span v-else-if="!command.disabled && group.inactive" class="flex-shrink-0 u-text-gray-500">{{ group.inactive }}</span>
<span v-else-if="!command.disabled && group.inactive" :class="ui.group.inactive">{{ group.inactive }}</span>
</slot>
</div>
</ComboboxOption>
@@ -55,73 +55,94 @@
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ComboboxOption } from '@headlessui/vue'
<script lang="ts">
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { ComboboxOption } from '@headlessui/vue'
import Icon from '../elements/Icon.vue'
import Avatar from '../elements/Avatar.vue'
import type { Group } from '../../types/command-palette'
import $ui from '#build/ui'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
group: {
type: Object as PropType<Group>,
required: true
// const appConfig = useAppConfig()
export default defineComponent({
components: {
ComboboxOption,
Icon,
Avatar
},
query: {
type: String,
default: ''
props: {
group: {
type: Object as PropType<Group>,
required: true
},
query: {
type: String,
default: ''
},
groupAttribute: {
type: String,
required: true
},
commandAttribute: {
type: String,
required: true
},
selectedIcon: {
type: String,
required: true
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.commandPalette>>,
default: () => appConfig.ui.commandPalette
}
},
groupAttribute: {
type: String,
required: true
},
commandAttribute: {
type: String,
required: true
setup (props) {
const label = computed(() => {
const label = props.group[props.groupAttribute]
return typeof label === 'function' ? label(props.query) : label
})
function highlight (text: string, { indices, value }: { indices: number[][], value:string }): string {
if (text === value) {
return ''
}
let content = ''
let nextUnhighlightedIndiceStartingIndex = 0
indices.forEach((indice) => {
const lastIndiceNextIndex = indice[1] + 1
const isMatched = (lastIndiceNextIndex - indice[0]) >= props.query.length
content += [
value.substring(nextUnhighlightedIndiceStartingIndex, indice[0]),
isMatched && '<mark>',
value.substring(indice[0], lastIndiceNextIndex),
isMatched && '</mark>'
].filter(Boolean).join('')
nextUnhighlightedIndiceStartingIndex = lastIndiceNextIndex
})
content += value.substring(nextUnhighlightedIndiceStartingIndex)
const index = content.indexOf('<mark>')
if (index > 60) {
content = `...${content.substring(index - 60)}`
}
return content
}
return {
label,
highlight
}
}
})
const label = computed(() => {
const label = props.group[props.groupAttribute]
return typeof label === 'function' ? label(props.query) : label
})
function highlight (text: string, { indices, value }: { indices: number[][], value:string }): string {
if (text === value) {
return ''
}
let content = ''
let nextUnhighlightedIndiceStartingIndex = 0
indices.forEach((indice) => {
const lastIndiceNextIndex = indice[1] + 1
const isMatched = (lastIndiceNextIndex - indice[0]) >= props.query.length
content += [
value.substring(nextUnhighlightedIndiceStartingIndex, indice[0]),
isMatched && '<mark>',
value.substring(indice[0], lastIndiceNextIndex),
isMatched && '</mark>'
].filter(Boolean).join('')
nextUnhighlightedIndiceStartingIndex = lastIndiceNextIndex
})
content += value.substring(nextUnhighlightedIndiceStartingIndex)
const index = content.indexOf('<mark>')
if (index > 60) {
content = `...${content.substring(index - 60)}`
}
return content
}
</script>
<script lang="ts">
export default { name: 'UCommandPaletteGroup' }
</script>

View File

@@ -1,49 +0,0 @@
<template>
<nav :class="wrapperClass">
<Link
v-for="(link, index) of links"
:key="index"
:to="link.to"
:exact="link.exact"
:class="baseClass"
:active-class="activeClass"
:inactive-class="inactiveClass"
>
{{ link.label }}
</Link>
</nav>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
import Link from '../elements/Link.vue'
import $ui from '#build/ui'
defineProps({
links: {
type: Array as PropType<{ to: RouteLocationNormalized, exact: boolean, label: string }[]>,
required: true
},
wrapperClass: {
type: String,
default: () => $ui.pills.wrapper
},
baseClass: {
type: String,
default: () => $ui.pills.base
},
activeClass: {
type: String,
default: () => $ui.pills.active
},
inactiveClass: {
type: String,
default: () => $ui.pills.inactive
}
})
</script>
<script lang="ts">
export default { name: 'UPills' }
</script>

View File

@@ -1,49 +0,0 @@
<template>
<nav :class="wrapperClass">
<Link
v-for="(link, index) of links"
:key="index"
:to="link.to"
:exact="link.exact"
:class="baseClass"
:active-class="activeClass"
:inactive-class="inactiveClass"
>
{{ link.label }}
</Link>
</nav>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
import Link from '../elements/Link.vue'
import $ui from '#build/ui'
defineProps({
links: {
type: Array as PropType<{ to: RouteLocationNormalized, exact: boolean, label: string }[]>,
required: true
},
wrapperClass: {
type: String,
default: () => $ui.tabs.wrapper
},
baseClass: {
type: String,
default: () => $ui.tabs.base
},
activeClass: {
type: String,
default: () => $ui.tabs.active
},
inactiveClass: {
type: String,
default: () => $ui.tabs.inactive
}
})
</script>
<script lang="ts">
export default { name: 'UTabs' }
</script>

View File

@@ -1,54 +1,67 @@
<template>
<nav :class="wrapperClass">
<Link
<nav :class="ui.wrapper">
<LinkCustom
v-for="(link, index) of links"
v-slot="{ isActive }"
:key="index"
v-bind="link"
:class="[baseClass, spacingClass].join(' ')"
:active-class="activeClass"
:inactive-class="inactiveClass"
:class="[ui.base, ui.spacing]"
:active-class="ui.active"
:inactive-class="ui.inactive"
@click="link.click && link.click()"
@keyup.enter="$event.target.blur()"
>
<slot name="avatar" :link="link">
<Avatar
v-if="link.avatar"
v-bind="{ size: 'xs', ...link.avatar }"
:class="[avatarBaseClass, link.label && avatarSpacingClass]"
v-bind="{ size: ui.avatar.size, ...link.avatar }"
:class="[ui.avatar.base]"
/>
</slot>
<slot name="icon" :link="link" :is-active="isActive">
<Icon
v-if="link.icon"
:name="link.icon"
:class="[iconBaseClass, link.label && iconSpacingClass, isActive ? iconActiveClass : iconInactiveClass, link.iconClass]"
:class="[ui.icon.base, isActive ? ui.icon.active : ui.icon.inactive, link.iconClass]"
/>
</slot>
<slot :link="link">
<span v-if="link.label" class="truncate">{{ link.label }}</span>
</slot>
<slot name="badge" :link="link" :is-active="isActive">
<span v-if="link.badge" :class="[badgeBaseClass, isActive ? badgeActiveClass : badgeInactiveClass]">
<span v-if="link.badge" :class="[ui.badge.baseClass, isActive ? ui.badge.active : ui.badge.inactive]">
{{ link.badge }}
</span>
</slot>
</Link>
</LinkCustom>
</nav>
</template>
<script setup lang="ts">
<script lang="ts">
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
import { defu } from 'defu'
import Icon from '../elements/Icon.vue'
import Link from '../elements/Link.vue'
import Avatar from '../elements/Avatar.vue'
import LinkCustom from '../elements/LinkCustom.vue'
import type { Avatar as AvatarType } from '../../types/avatar'
import $ui from '#build/ui'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
defineProps({
links: {
type: Array as PropType<{
// const appConfig = useAppConfig()
export default defineComponent({
components: {
Icon,
Avatar,
LinkCustom
},
props: {
links: {
type: Array as PropType<{
to?: RouteLocationNormalized | string
exact?: boolean
label: string
@@ -58,67 +71,23 @@ defineProps({
click?: Function
badge?: string
}[]>,
required: true
default: () => []
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.verticalNavigation>>,
default: () => appConfig.ui.verticalNavigation
}
},
wrapperClass: {
type: String,
default: () => $ui.verticalNavigation.wrapper
},
baseClass: {
type: String,
default: () => $ui.verticalNavigation.base
},
spacingClass: {
type: String,
default: () => $ui.verticalNavigation.spacing
},
activeClass: {
type: String,
default: () => $ui.verticalNavigation.active
},
inactiveClass: {
type: String,
default: () => $ui.verticalNavigation.inactive
},
iconBaseClass: {
type: String,
default: () => $ui.verticalNavigation.icon.base
},
iconSpacingClass: {
type: String,
default: () => $ui.verticalNavigation.icon.spacing
},
iconActiveClass: {
type: String,
default: () => $ui.verticalNavigation.icon.active
},
iconInactiveClass: {
type: String,
default: () => $ui.verticalNavigation.icon.inactive
},
avatarBaseClass: {
type: String,
default: () => $ui.verticalNavigation.avatar.base
},
avatarSpacingClass: {
type: String,
default: () => $ui.verticalNavigation.avatar.spacing
},
badgeBaseClass: {
type: String,
default: () => $ui.verticalNavigation.badge.base
},
badgeActiveClass: {
type: String,
default: () => $ui.verticalNavigation.badge.active
},
badgeInactiveClass: {
type: String,
default: () => $ui.verticalNavigation.badge.inactive
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.verticalNavigation>>(() => defu({}, props.ui, appConfig.ui.verticalNavigation))
return {
// eslint-disable-next-line vue/no-dupe-keys
ui
}
}
})
</script>
<script lang="ts">
export default { name: 'UVerticalNavigation' }
</script>

View File

@@ -1,91 +1,79 @@
<template>
<div v-if="isOpen" ref="container" :class="[containerClass, widthClass]">
<transition appear v-bind="transitionClass">
<div :class="[baseClass, ringClass, roundedClass, shadowClass, backgroundClass]">
<div v-if="isOpen" ref="container" :class="[ui.container, ui.width]">
<transition appear v-bind="ui.transition">
<div :class="[ui.base, ui.ring, ui.rounded, ui.shadow, ui.background]">
<slot />
</div>
</transition>
</div>
</template>
<script setup lang="ts">
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType, Ref } from 'vue'
import { computed, toRef } from 'vue'
import { defu } from 'defu'
import { onClickOutside } from '@vueuse/core'
import type { VirtualElement } from '@popperjs/core'
import { usePopper } from '../../composables/usePopper'
import type { PopperOptions } from '../../types'
import $ui from '#build/ui'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
// const appConfig = useAppConfig()
export default defineComponent({
props: {
modelValue: {
type: Boolean,
default: false
},
virtualElement: {
type: Object,
required: true
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.contextMenu>>,
default: () => appConfig.ui.contextMenu
}
},
virtualElement: {
type: Object,
required: true
},
wrapperClass: {
type: String,
default: () => $ui.contextMenu.wrapper
},
containerClass: {
type: String,
default: () => $ui.contextMenu.container
},
widthClass: {
type: String,
default: () => $ui.contextMenu.width
},
backgroundClass: {
type: String,
default: () => $ui.contextMenu.background
},
shadowClass: {
type: String,
default: () => $ui.contextMenu.shadow
},
roundedClass: {
type: String,
default: () => $ui.contextMenu.rounded
},
ringClass: {
type: String,
default: () => $ui.contextMenu.ring
},
baseClass: {
type: String,
default: () => $ui.contextMenu.base
},
transitionClass: {
type: Object,
default: () => $ui.contextMenu.transition
},
popperOptions: {
type: Object as PropType<PopperOptions>,
default: () => ({})
emits: ['update:modelValue', 'close'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.contextMenu>>(() => defu({}, props.ui, appConfig.ui.contextMenu))
const popper = computed<PopperOptions>(() => defu({}, props.popper, ui.value.popper as PopperOptions))
const isOpen = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const virtualElement = toRef(props, 'virtualElement') as Ref<VirtualElement>
const [, container] = usePopper(popper.value, virtualElement)
onClickOutside(container, () => {
isOpen.value = false
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
isOpen,
container
}
}
})
const emit = defineEmits(['update:modelValue', 'close'])
const isOpen = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const virtualElement = toRef(props, 'virtualElement') as Ref<VirtualElement>
const popperOptions = computed<PopperOptions>(() => defu({}, props.popperOptions, $ui.contextMenu.popperOptions))
const [, container] = usePopper(popperOptions.value, virtualElement)
</script>
<script lang="ts">
export default { name: 'UContextMenu' }
</script>

View File

@@ -1,39 +1,15 @@
<template>
<TransitionRoot :appear="appear" :show="isOpen" as="template">
<Dialog :class="wrapperClass" @close="close">
<TransitionChild
v-if="overlay"
as="template"
:appear="appear"
v-bind="overlayTransition"
>
<div class="fixed inset-0 transition-opacity" :class="overlayBackgroundClass" />
<Dialog :class="ui.wrapper" @close="close">
<TransitionChild v-if="overlay" as="template" :appear="appear" v-bind="ui.overlay.transition">
<div :class="[ui.overlay.base, ui.overlay.background]" />
</TransitionChild>
<div :class="innerClass" :style="innerStyle">
<div :class="containerClass">
<TransitionChild
as="template"
:appear="appear"
v-bind="modalTransition"
>
<DialogPanel :class="modalClass">
<Card
base-class=""
background-class=""
shadow-class=""
ring-class=""
rounded-class=""
v-bind="$attrs"
>
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<slot />
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</Card>
<div :class="ui.inner">
<div :class="[ui.container, ui.spacing]">
<TransitionChild as="template" :appear="appear" v-bind="ui.transition">
<DialogPanel :class="[ui.base, ui.width, ui.height, ui.background, ui.ring, ui.rounded, ui.shadow]">
<slot />
</DialogPanel>
</TransitionChild>
</div>
@@ -42,131 +18,75 @@
</TransitionRoot>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Dialog, DialogPanel, TransitionRoot, TransitionChild } from '@headlessui/vue'
import { classNames } from '../../utils'
import Card from '../layout/Card.vue'
import $ui from '#build/ui'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
appear: {
type: Boolean,
default: false
},
wrapperClass: {
type: String,
default: () => $ui.modal.wrapper
},
innerClass: {
type: String,
default: () => $ui.modal.inner
},
innerStyle: {
type: Object,
default: () => ({})
},
containerClass: {
type: String,
default: () => $ui.modal.container
},
baseClass: {
type: String,
default: () => $ui.modal.base
},
backgroundClass: {
type: String,
default: () => $ui.modal.background
},
overlay: {
type: Boolean,
default: true
},
overlayBackgroundClass: {
type: String,
default: () => $ui.modal.overlay.background
},
overlayTransitionClass: {
type: Object,
default: () => $ui.modal.overlay.transition
},
shadowClass: {
type: String,
default: () => $ui.modal.shadow
},
ringClass: {
type: String,
default: () => $ui.modal.ring
},
roundedClass: {
type: String,
default: () => $ui.modal.rounded
},
widthClass: {
type: String,
default: () => $ui.modal.width
},
transition: {
type: Boolean,
default: true
},
transitionClass: {
type: Object,
default: () => $ui.modal.transition
}
})
const emit = defineEmits(['update:modelValue', 'close'])
const isOpen = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const modalClass = computed(() => {
return classNames(
props.baseClass,
props.widthClass,
props.backgroundClass,
props.shadowClass,
props.ringClass,
props.roundedClass
)
})
const overlayTransition = computed(() => {
if (!props.transition) {
return {}
}
return props.overlayTransitionClass
})
const modalTransition = computed(() => {
if (!props.transition) {
return {}
}
return props.transitionClass
})
function close (value: boolean) {
isOpen.value = value
emit('close')
}
</script>
<script lang="ts">
export default {
name: 'UModal',
inheritAttrs: false
}
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { Dialog, DialogPanel, TransitionRoot, TransitionChild } from '@headlessui/vue'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
components: {
// eslint-disable-next-line vue/no-reserved-component-names
Dialog,
DialogPanel,
TransitionRoot,
TransitionChild
},
props: {
modelValue: {
type: Boolean,
default: false
},
appear: {
type: Boolean,
default: false
},
overlay: {
type: Boolean,
default: true
},
transition: {
type: Boolean,
default: true
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.modal>>,
default: () => appConfig.ui.modal
}
},
emits: ['update:modelValue', 'close'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.modal>>(() => defu({}, props.ui, appConfig.ui.modal))
const isOpen = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
function close (value: boolean) {
isOpen.value = value
emit('close')
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
isOpen,
close
}
}
})
</script>

View File

@@ -1,211 +1,191 @@
<template>
<transition appear v-bind="transitionClass">
<transition appear v-bind="ui.transition">
<div
:class="['z-50 w-full pointer-events-auto', backgroundClass, roundedClass, shadowClass]"
:class="[ui.wrapper, ui.background, ui.rounded, ui.shadow]"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
>
<div :class="['relative overflow-hidden', roundedClass, ringClass]">
<div :class="[ui.container, ui.rounded, ui.ring]">
<div class="p-4">
<div class="flex gap-3" :class="{ 'items-start': description, 'items-center': !description }">
<div v-if="iconName" class="flex-shrink-0">
<Icon :name="iconName" :class="iconClass" />
</div>
<Icon v-if="icon" :name="icon" :class="ui.icon" />
<Avatar v-if="avatar" v-bind="avatar" :class="ui.avatar" />
<div class="w-0 flex-1">
<p class="text-sm font-medium u-text-gray-900">
<p :class="ui.title">
{{ title }}
</p>
<p v-if="description" class="mt-1 text-sm leading-5 u-text-gray-500">
<p v-if="description" :class="ui.description">
{{ description }}
</p>
<div v-if="description && actions.length" class="mt-3 flex items-center gap-6">
<button v-for="(action, index) of actions" :key="index" type="button" class="text-sm font-medium focus:outline-none text-primary-500 dark:text-primary-400 hover:text-primary-400 dark:hover:text-primary-500" @click.stop="onAction(action)">
{{ action.label }}
</button>
<div v-if="description && actions.length" class="mt-3 flex items-center gap-2">
<Button v-for="(action, index) of actions" :key="index" v-bind="{ ...ui.default.action, ...action }" @click.stop="onAction(action)" />
</div>
</div>
<div class="flex-shrink-0 flex items-center gap-3">
<div v-if="!description && actions.length" class="flex items-center gap-2">
<button v-for="(action, index) of actions" :key="index" type="button" class="text-sm font-medium focus:outline-none text-primary-500 dark:text-primary-400 hover:text-primary-400 dark:hover:text-primary-500" @click.stop="onAction(action)">
{{ action.label }}
</button>
<Button v-for="(action, index) of actions" :key="index" v-bind="{ ...ui.default.action, ...action }" @click.stop="onAction(action)" />
</div>
<button
class="inline-flex 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="$ui.notification.close.icon.name" class="w-5 h-5" />
</button>
<Button v-if="close" v-bind="{ ...ui.default.close, ...close }" @click.stop="onClose" />
</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 v-if="timeout" :class="ui.progress" :style="progressBarStyle" />
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watchEffect } from 'vue'
<script lang="ts">
import { ref, computed, onMounted, onUnmounted, watchEffect, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import Icon from '../elements/Icon.vue'
import Avatar from '../elements/Avatar.vue'
import Button from '../elements/Button.vue'
import { useTimer } from '../../composables/useTimer'
import { classNames } from '../../utils'
import type { ToastNotificationAction } from '../../types'
import $ui from '#build/ui'
import type { Avatar as AvatarType } from '../../types/avatar'
import type { Button as ButtonType } from '../../types/button'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
id: {
type: String,
required: true
// const appConfig = useAppConfig()
export default defineComponent({
components: {
Icon,
Avatar,
// eslint-disable-next-line vue/no-reserved-component-names
Button
},
type: {
type: String,
default: null,
validator (value: string) {
return Object.keys($ui.notification.type).includes(value)
props: {
id: {
type: [String, Number],
required: true
},
title: {
type: String,
required: true
},
description: {
type: String,
default: null
},
icon: {
type: String,
default: null
},
avatar: {
type: Object as PropType<Partial<AvatarType>>,
default: null
},
close: {
type: Object as PropType<Partial<ButtonType>>,
default: () => appConfig.ui.notification.default.close
},
timeout: {
type: Number,
default: 5000
},
actions: {
type: Array as PropType<ToastNotificationAction[]>,
default: () => []
},
callback: {
type: Function,
default: null
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.notification>>,
default: () => appConfig.ui.notification
}
},
title: {
type: String,
required: true
},
description: {
type: String,
default: null
},
backgroundClass: {
type: String,
default: () => $ui.notification.background
},
shadowClass: {
type: String,
default: () => $ui.notification.shadow
},
ringClass: {
type: String,
default: () => $ui.notification.ring
},
roundedClass: {
type: String,
default: () => $ui.notification.rounded
},
transitionClass: {
type: Object,
default: () => $ui.notification.transition
},
customClass: {
type: String,
default: null
},
icon: {
type: String,
default: null
},
iconBaseClass: {
type: String,
default: () => $ui.notification.icon.base
},
timeout: {
type: Number,
default: 5000
},
actions: {
type: Array as PropType<{
label: string,
click: Function
}[]>,
default: () => []
},
callback: {
type: Function,
default: null
}
})
emits: ['close'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const emit = defineEmits(['close'])
const ui = computed<Partial<typeof appConfig.ui.notification>>(() => defu({}, props.ui, appConfig.ui.notification))
let timer: any = null
const remaining = ref(props.timeout)
let timer: any = null
const remaining = ref(props.timeout)
const iconName = computed(() => {
return props.icon || $ui.notification.type[props.type]
})
const progressBarStyle = computed(() => {
const remainingPercent = remaining.value / props.timeout * 100
const iconClass = computed(() => {
return classNames(
props.iconBaseClass,
$ui.notification.icon.color[props.type] || 'u-text-gray-400'
)
})
return { width: `${remainingPercent || 0}%` }
})
const progressBarStyle = computed(() => {
const remainingPercent = remaining.value / props.timeout * 100
return { width: `${remainingPercent || 0}%` }
})
function onMouseover () {
if (timer) {
timer.pause()
}
}
function onMouseover () {
if (timer) {
timer.pause()
}
}
function onMouseleave () {
if (timer) {
timer.resume()
}
}
function onMouseleave () {
if (timer) {
timer.resume()
}
}
function onClose () {
if (timer) {
timer.stop()
}
function onClose () {
if (timer) {
timer.stop()
}
if (props.callback) {
props.callback()
}
if (props.callback) {
props.callback()
}
emit('close')
}
emit('close')
}
function onAction (action: ToastNotificationAction) {
if (timer) {
timer.stop()
}
function onAction (action: ToastNotificationAction) {
if (timer) {
timer.stop()
}
if (action.click) {
action.click()
}
if (action.click) {
action.click()
}
emit('close')
}
emit('close')
}
onMounted(() => {
if (!props.timeout) {
return
}
onMounted(() => {
if (!props.timeout) {
return
}
timer = useTimer(() => {
onClose()
}, props.timeout)
timer = useTimer(() => {
onClose()
}, props.timeout)
watchEffect(() => {
remaining.value = timer.remaining.value
})
})
watchEffect(() => {
remaining.value = timer.remaining.value
})
})
onUnmounted(() => {
if (timer) {
timer.stop()
}
})
onUnmounted(() => {
if (timer) {
timer.stop()
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
progressBarStyle,
onMouseover,
onMouseleave,
onClose,
onAction
}
}
})
</script>
<script lang="ts">
export default { name: 'UNotification' }
</script>

View File

@@ -1,31 +1,57 @@
<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">
<div
v-for="notification of notifications"
:key="notification.id"
>
<div :class="ui.wrapper">
<div v-if="notifications.length" :class="ui.container">
<div v-for="notification of notifications" :key="notification.id">
<Notification
v-bind="notification"
:class="notification.click && 'cursor-pointer'"
@click="notification.click && notification.click(notification)"
@close="toast.removeNotification(notification.id)"
@close="toast.remove(notification.id)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
<script lang="ts">
import { defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import type { ToastNotification } from '../../types'
import { useToast } from '../../composables/useToast'
import Notification from './Notification.vue'
import { useState } from '#imports'
import { useState, useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const toast = useToast()
const notifications = useState<ToastNotification[]>('notifications', () => [])
</script>
// const appConfig = useAppConfig()
<script lang="ts">
export default { name: 'UNotifications' }
export default defineComponent({
components: {
Notification
},
props: {
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.notifications>>,
default: () => appConfig.ui.notifications
}
},
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.notifications>>(() => defu({}, props.ui, appConfig.ui.notifications))
const toast = useToast()
const notifications = useState<ToastNotification[]>('notifications', () => [])
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
toast,
notifications
}
}
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<Popover v-slot="{ open, close }" :class="wrapperClass" @mouseleave="onMouseLeave">
<Popover v-slot="{ open, close }" :class="ui.wrapper" @mouseleave="onMouseLeave">
<PopoverButton
ref="trigger"
as="div"
@@ -15,9 +15,9 @@
</slot>
</PopoverButton>
<div v-if="open" ref="container" :class="[containerClass, widthClass]" @mouseover="onMouseOver">
<transition appear v-bind="transitionClass">
<PopoverPanel :class="[baseClass, ringClass, roundedClass, shadowClass, backgroundClass]" static>
<div v-if="open" ref="container" :class="[ui.container, ui.width]" @mouseover="onMouseOver">
<transition appear v-bind="ui.transition">
<PopoverPanel :class="[ui.base, ui.background, ui.ring, ui.rounded, ui.shadow]" static>
<slot name="panel" :open="open" :close="close" />
</PopoverPanel>
</transition>
@@ -25,140 +25,131 @@
</Popover>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
<script lang="ts">
import { computed, ref, onMounted, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
import { usePopper } from '../../composables/usePopper'
import type { PopperOptions } from '../../types'
import $ui from '#build/ui'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
mode: {
type: String,
default: 'click',
validator: (value: string) => {
return ['click', 'hover'].includes(value)
// const appConfig = useAppConfig()
export default defineComponent({
components: {
Popover,
PopoverButton,
PopoverPanel
},
props: {
mode: {
type: String,
default: 'click',
validator: (value: string) => {
return ['click', 'hover'].includes(value)
}
},
disabled: {
type: Boolean,
default: false
},
openDelay: {
type: Number,
default: 50
},
closeDelay: {
type: Number,
default: 0
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.popover>>,
default: () => appConfig.ui.popover
}
},
disabled: {
type: Boolean,
default: false
},
wrapperClass: {
type: String,
default: () => $ui.popover.wrapper
},
containerClass: {
type: String,
default: () => $ui.popover.container
},
widthClass: {
type: String,
default: () => $ui.popover.width
},
baseClass: {
type: String,
default: () => $ui.popover.base
},
backgroundClass: {
type: String,
default: () => $ui.popover.background
},
shadowClass: {
type: String,
default: () => $ui.popover.shadow
},
roundedClass: {
type: String,
default: () => $ui.popover.rounded
},
ringClass: {
type: String,
default: () => $ui.popover.ring
},
transitionClass: {
type: Object,
default: () => $ui.popover.transition
},
popperOptions: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
openDelay: {
type: Number,
default: 50
},
closeDelay: {
type: Number,
default: 0
}
})
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const popperOptions = computed<PopperOptions>(() => defu({}, props.popperOptions, $ui.popover.popperOptions))
const ui = computed<Partial<typeof appConfig.ui.popover>>(() => defu({}, props.ui, appConfig.ui.popover))
const [trigger, container] = usePopper(popperOptions.value)
const popper = computed<PopperOptions>(() => defu({}, props.popper, ui.value.popper as PopperOptions))
// https://github.com/tailwindlabs/headlessui/blob/f66f4926c489fc15289d528294c23a3dc2aee7b1/packages/%40headlessui-vue/src/components/popover/popover.ts#L151
const popoverApi = ref<any>(null)
const [trigger, container] = usePopper(popper.value)
let openTimeout: NodeJS.Timeout | null = null
let closeTimeout: NodeJS.Timeout | null = null
// https://github.com/tailwindlabs/headlessui/blob/f66f4926c489fc15289d528294c23a3dc2aee7b1/packages/%40headlessui-vue/src/components/popover/popover.ts#L151
const popoverApi = ref<any>(null)
onMounted(() => {
setTimeout(() => {
// @ts-expect-error internals
const popoverProvides = trigger.value?.$.provides
if (!popoverProvides) {
return
let openTimeout: NodeJS.Timeout | null = null
let closeTimeout: NodeJS.Timeout | null = null
onMounted(() => {
setTimeout(() => {
// @ts-expect-error internals
const popoverProvides = trigger.value?.$.provides
if (!popoverProvides) {
return
}
const popoverProvidesSymbols = Object.getOwnPropertySymbols(popoverProvides)
popoverApi.value = popoverProvidesSymbols.length && popoverProvides[popoverProvidesSymbols[0]]
}, 200)
})
function onMouseOver () {
if (props.mode !== 'hover' || !popoverApi.value) {
return
}
// cancel programmed closing
if (closeTimeout) {
clearTimeout(closeTimeout)
closeTimeout = null
}
// dropdown already open
if (popoverApi.value.popoverState === 0) {
return
}
openTimeout = openTimeout || setTimeout(() => {
popoverApi.value.togglePopover && popoverApi.value.togglePopover()
openTimeout = null
}, props.openDelay)
}
const popoverProvidesSymbols = Object.getOwnPropertySymbols(popoverProvides)
popoverApi.value = popoverProvidesSymbols.length && popoverProvides[popoverProvidesSymbols[0]]
}, 200)
function onMouseLeave () {
if (props.mode !== 'hover' || !popoverApi.value) {
return
}
// cancel programmed opening
if (openTimeout) {
clearTimeout(openTimeout)
openTimeout = null
}
// dropdown already closed
if (popoverApi.value.popoverState === 1) {
return
}
closeTimeout = closeTimeout || setTimeout(() => {
popoverApi.value.closePopover && popoverApi.value.closePopover()
closeTimeout = null
}, props.closeDelay)
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
trigger,
container,
onMouseOver,
onMouseLeave
}
}
})
function onMouseOver () {
if (props.mode !== 'hover' || !popoverApi.value) {
return
}
// cancel programmed closing
if (closeTimeout) {
clearTimeout(closeTimeout)
closeTimeout = null
}
// dropdown already open
if (popoverApi.value.popoverState === 0) {
return
}
openTimeout = openTimeout || setTimeout(() => {
popoverApi.value.togglePopover && popoverApi.value.togglePopover()
openTimeout = null
}, props.openDelay)
}
function onMouseLeave () {
if (props.mode !== 'hover' || !popoverApi.value) {
return
}
// cancel programmed opening
if (openTimeout) {
clearTimeout(openTimeout)
openTimeout = null
}
// dropdown already closed
if (popoverApi.value.popoverState === 1) {
return
}
closeTimeout = closeTimeout || setTimeout(() => {
popoverApi.value.closePopover && popoverApi.value.closePopover()
closeTimeout = null
}, props.closeDelay)
}
</script>
<script lang="ts">
export default { name: 'UPopover' }
</script>

View File

@@ -1,24 +1,12 @@
<template>
<TransitionRoot as="template" :appear="appear" :show="isOpen">
<Dialog :class="[wrapperClass, { 'justify-end': side === 'right' }]" @close="close">
<TransitionChild
v-if="overlay"
as="template"
:appear="appear"
v-bind="overlayTransition"
>
<div class="fixed inset-0 transition-opacity" :class="overlayBackgroundClass" />
<Dialog :class="[ui.wrapper, { 'justify-end': side === 'right' }]" @close="close">
<TransitionChild v-if="overlay" as="template" :appear="appear" v-bind="ui.overlay.transition">
<div :class="[ui.overlay.base, ui.overlay.background]" />
</TransitionChild>
<TransitionChild
as="template"
:appear="appear"
v-bind="slideoverTransition"
>
<DialogPanel :class="slideoverClass">
<div v-if="$slots.header" :class="headerClass">
<slot name="header" />
</div>
<TransitionChild as="template" :appear="appear" v-bind="transitionClass">
<DialogPanel :class="[ui.base, ui.width, ui.background, ui.ring]">
<slot />
</DialogPanel>
</TransitionChild>
@@ -26,116 +14,95 @@
</TransitionRoot>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { WritableComputedRef, PropType } from 'vue'
import { Dialog, DialogPanel, TransitionRoot, TransitionChild } from '@headlessui/vue'
import { classNames } from '../../utils'
import $ui from '#build/ui'
const props = defineProps({
modelValue: {
type: Boolean as PropType<boolean>,
default: false
},
appear: {
type: Boolean,
default: false
},
side: {
type: String,
default: 'left',
validator: (value: string) => ['left', 'right'].includes(value)
},
wrapperClass: {
type: String,
default: () => $ui.slideover.wrapper
},
baseClass: {
type: String,
default: () => $ui.slideover.base
},
backgroundClass: {
type: String,
default: () => $ui.slideover.background
},
overlay: {
type: Boolean,
default: true
},
overlayBackgroundClass: {
type: String,
default: () => $ui.slideover.overlay.background
},
overlayTransitionClass: {
type: Object,
default: () => $ui.slideover.overlay.transition
},
widthClass: {
type: String,
default: () => $ui.slideover.width
},
headerClass: {
type: String,
default: () => $ui.slideover.header
},
transition: {
type: Boolean,
default: true
},
transitionClass: {
type: Object,
default: () => $ui.slideover.transition
}
})
const emit = defineEmits(['update:modelValue', 'close'])
const isOpen: WritableComputedRef<boolean> = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const slideoverClass = computed(() => {
return classNames(
props.baseClass,
props.widthClass,
props.backgroundClass
)
})
const overlayTransition = computed(() => {
if (!props.transition) {
return {}
}
return props.overlayTransitionClass
})
const slideoverTransition = computed(() => {
if (!props.transition) {
return {}
}
return {
enterFrom: props.side === 'left' ? '-translate-x-full' : 'translate-x-full',
enterTo: 'translate-x-0',
leaveFrom: 'translate-x-0',
leaveTo: props.side === 'left' ? '-translate-x-full' : 'translate-x-full',
...props.transitionClass
}
})
function close (value: boolean) {
isOpen.value = value
emit('close')
}
</script>
<script lang="ts">
export default { name: 'USlideover' }
import { computed, defineComponent } from 'vue'
import type { WritableComputedRef, PropType } from 'vue'
import { defu } from 'defu'
import { Dialog, DialogPanel, TransitionRoot, TransitionChild } from '@headlessui/vue'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
// const appConfig = useAppConfig()
export default defineComponent({
components: {
// eslint-disable-next-line vue/no-reserved-component-names
Dialog,
DialogPanel,
TransitionRoot,
TransitionChild
},
props: {
modelValue: {
type: Boolean as PropType<boolean>,
default: false
},
appear: {
type: Boolean,
default: false
},
side: {
type: String,
default: 'left',
validator: (value: string) => ['left', 'right'].includes(value)
},
overlay: {
type: Boolean,
default: true
},
transition: {
type: Boolean,
default: true
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.slideover>>,
default: () => appConfig.ui.slideover
}
},
emits: ['update:modelValue', 'close'],
setup (props, { emit }) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.slideover>>(() => defu({}, props.ui, appConfig.ui.slideover))
const isOpen: WritableComputedRef<boolean> = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const transitionClass = computed(() => {
if (!props.transition) {
return {}
}
return {
...ui.value.transition,
enterFrom: props.side === 'left' ? '-translate-x-full' : 'translate-x-full',
enterTo: 'translate-x-0',
leaveFrom: 'translate-x-0',
leaveTo: props.side === 'left' ? '-translate-x-full' : 'translate-x-full'
}
})
function close (value: boolean) {
isOpen.value = value
emit('close')
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
isOpen,
transitionClass,
close
}
}
})
</script>

View File

@@ -1,19 +1,19 @@
<template>
<div ref="trigger" :class="wrapperClass" @mouseover="onMouseOver" @mouseleave="onMouseLeave">
<div ref="trigger" :class="ui.wrapper" @mouseover="onMouseOver" @mouseleave="onMouseLeave">
<slot :open="open">
Hover me
hover
</slot>
<div v-if="open && !prevent" ref="container" :class="[containerClass, widthClass]">
<transition appear v-bind="transitionClass">
<div :class="[baseClass, backgroundClass, roundedClass, shadowClass, ringClass]">
<div v-if="open && !prevent" ref="container" :class="[ui.container, ui.width]">
<transition appear v-bind="ui.transition">
<div :class="[ui.base, ui.background, ui.rounded, ui.shadow, ui.ring]">
<slot name="text">
{{ text }}
</slot>
<span v-if="shortcuts?.length" :class="shortcutsClass">
<span class="mr-1 u-text-gray-700">&middot;</span>
<kbd v-for="shortcut of shortcuts" :key="shortcut" class="flex items-center justify-center font-sans px-1 h-4 min-w-[16px] text-[10px] u-bg-gray-100 rounded u-text-gray-900">
<span v-if="shortcuts?.length" :class="ui.shortcuts">
<span class="mr-1 text-gray-700 dark:text-gray-200">&middot;</span>
<kbd v-for="shortcut of shortcuts" :key="shortcut" class="flex items-center justify-center font-sans px-1 h-4 min-w-[16px] text-[10px] bg-gray-100 dark:bg-gray-800 rounded text-gray-900 dark:text-white">
{{ shortcut }}
</kbd>
</span>
@@ -23,125 +23,108 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts">
import { computed, ref, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { computed, ref } from 'vue'
import { defu } from 'defu'
import { usePopper } from '../../composables/usePopper'
import type { PopperOptions } from '../../types'
import $ui from '#build/ui'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'
const props = defineProps({
text: {
type: String,
default: null
// const appConfig = useAppConfig()
export default defineComponent({
props: {
text: {
type: String,
default: null
},
prevent: {
type: Boolean,
default: false
},
shortcuts: {
type: Array as PropType<string[]>,
default: () => []
},
openDelay: {
type: Number,
default: 0
},
closeDelay: {
type: Number,
default: 0
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
ui: {
type: Object as PropType<Partial<typeof appConfig.ui.tooltip>>,
default: () => appConfig.ui.tooltip
}
},
prevent: {
type: Boolean,
default: false
},
shortcuts: {
type: Array as PropType<string[]>,
default: () => []
},
wrapperClass: {
type: String,
default: () => $ui.tooltip.wrapper
},
containerClass: {
type: String,
default: () => $ui.tooltip.container
},
widthClass: {
type: String,
default: () => $ui.tooltip.width
},
backgroundClass: {
type: String,
default: () => $ui.tooltip.background
},
shadowClass: {
type: String,
default: () => $ui.tooltip.shadow
},
ringClass: {
type: String,
default: () => $ui.tooltip.ring
},
roundedClass: {
type: String,
default: () => $ui.tooltip.rounded
},
baseClass: {
type: String,
default: () => $ui.tooltip.base
},
transitionClass: {
type: Object,
default: () => $ui.tooltip.transition
},
popperOptions: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
shortcutsClass: {
type: String,
default: () => $ui.tooltip.shortcuts
},
openDelay: {
type: Number,
default: 0
},
closeDelay: {
type: Number,
default: 0
setup (props) {
// TODO: Remove
const appConfig = useAppConfig()
const ui = computed<Partial<typeof appConfig.ui.tooltip>>(() => defu({}, props.ui, appConfig.ui.tooltip))
const popper = computed<PopperOptions>(() => defu({}, props.popper, ui.value.popper as PopperOptions))
const [trigger, container] = usePopper(popper.value)
const open = ref(false)
let openTimeout: NodeJS.Timeout | null = null
let closeTimeout: NodeJS.Timeout | null = null
// Methods
function onMouseOver () {
// cancel programmed closing
if (closeTimeout) {
clearTimeout(closeTimeout)
closeTimeout = null
}
// dropdown already open
if (open.value) {
return
}
openTimeout = openTimeout || setTimeout(() => {
open.value = true
openTimeout = null
}, props.openDelay)
}
function onMouseLeave () {
// cancel programmed opening
if (openTimeout) {
clearTimeout(openTimeout)
openTimeout = null
}
// dropdown already closed
if (!open.value) {
return
}
closeTimeout = closeTimeout || setTimeout(() => {
open.value = false
closeTimeout = null
}, props.closeDelay)
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
trigger,
container,
open,
onMouseOver,
onMouseLeave
}
}
})
const popperOptions = computed<PopperOptions>(() => defu({}, props.popperOptions, $ui.tooltip.popperOptions))
const [trigger, container] = usePopper(popperOptions.value)
const open = ref(false)
let openTimeout: NodeJS.Timeout | null = null
let closeTimeout: NodeJS.Timeout | null = null
// Methods
function onMouseOver () {
// cancel programmed closing
if (closeTimeout) {
clearTimeout(closeTimeout)
closeTimeout = null
}
// dropdown already open
if (open.value) {
return
}
openTimeout = openTimeout || setTimeout(() => {
open.value = true
openTimeout = null
}, props.openDelay)
}
function onMouseLeave () {
// cancel programmed opening
if (openTimeout) {
clearTimeout(openTimeout)
openTimeout = null
}
// dropdown already closed
if (!open.value) {
return
}
closeTimeout = closeTimeout || setTimeout(() => {
open.value = false
closeTimeout = null
}, props.closeDelay)
}
</script>
<script lang="ts">
export default { name: 'UTooltip' }
</script>