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>