feat(InputMenu): new component (#86)

This commit is contained in:
Benjamin Canac
2024-05-06 18:59:49 +02:00
committed by GitHub
parent f409395297
commit 99f20a4154
59 changed files with 3345 additions and 1265 deletions

View File

@@ -31,9 +31,8 @@ export interface AccordionEmits extends AccordionRootEmits {}
type SlotProps<T> = (props: { item: T, index: number }) => any
export type AccordionSlots<T extends { slot?: string }> = {
default: SlotProps<T>
leading: SlotProps<T>
label: SlotProps<T>
default: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
@@ -64,18 +63,16 @@ const ui = computed(() => tv({ extend: accordion, slots: props.ui })({ disabled:
<AccordionItem v-for="(item, index) in items" :key="index" :value="item.value || String(index)" :disabled="item.disabled" :class="ui.item()">
<AccordionHeader :class="ui.header()">
<AccordionTrigger :class="ui.trigger({ disabled: item.disabled })">
<slot :item="item" :index="index">
<slot name="leading" :item="item" :index="index">
<UIcon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon()" />
</slot>
<slot name="leading" :item="item" :index="index">
<UIcon v-if="item.icon" :name="item.icon" :class="ui.leadingIcon()" />
</slot>
<span v-if="item.label || $slots.label" :class="ui.label()">
<slot name="label" :item="item" :index="index">{{ item.label }}</slot>
</span>
<span v-if="item.label || $slots.default" :class="ui.label()">
<slot :item="item" :index="index">{{ item.label }}</slot>
</span>
<slot name="trailing" :item="item" :index="index">
<UIcon :name="item.trailingIcon || trailingIcon || appConfig.ui.icons.chevronDown" :class="ui.trailingIcon()" />
</slot>
<slot name="trailing" :item="item" :index="index">
<UIcon :name="item.trailingIcon || trailingIcon || appConfig.ui.icons.chevronDown" :class="ui.trailingIcon()" />
</slot>
</AccordionTrigger>
</AccordionHeader>

View File

@@ -28,11 +28,11 @@ export interface BreadcrumbProps<T> extends Omit<PrimitiveProps, 'asChild'> {
type SlotProps<T> = (props: { item: T, index: number, active?: boolean }) => any
export type BreadcrumbSlots<T extends { slot?: string }> = {
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
item: SlotProps<T>
separator(): any
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
'separator'(): any
} & DynamicSlots<T, SlotProps<T>>
</script>
@@ -55,21 +55,21 @@ const ui = computed(() => tv({ extend: breadcrumb, slots: props.ui })())
<Primitive :as="as" aria-label="breadcrumb" :class="ui.root({ class: props.class })">
<ol :class="ui.list()">
<template v-for="(item, index) in items" :key="index">
<li :class="ui.item()">
<ULink as="span" v-bind="omit(item, ['label', 'icon', 'avatar', 'slot'])" :aria-current="index === items!.length - 1 ? 'page' : undefined" :class="ui.link({ active: index === items!.length - 1, disabled: !!item.disabled, to: !!item.to })" raw>
<li :class="ui.itemWrapper()">
<ULink as="span" v-bind="omit(item, ['label', 'icon', 'avatar', 'slot'])" :aria-current="index === items!.length - 1 ? 'page' : undefined" :class="ui.item({ active: index === items!.length - 1, disabled: !!item.disabled, to: !!item.to })" raw>
<slot :name="item.slot || 'item'" :item="item" :index="index">
<slot name="leading" :item="item" :active="index === items!.length - 1" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ active: index === items!.length - 1 })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ active: index === items!.length - 1 })" />
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="item" :active="index === items!.length - 1" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ active: index === items!.length - 1 })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ active: index === items!.length - 1 })" />
</slot>
<span v-if="item.label || $slots.label" :class="ui.linkLabel()">
<slot name="label" :item="item" :active="index === items!.length - 1" :index="index">
<span v-if="item.label || $slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel()">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="item" :active="index === items!.length - 1" :index="index">
{{ item.label }}
</slot>
</span>
<slot name="trailing" :item="item" :active="index === items!.length - 1" :index="index" />
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="item" :active="index === items!.length - 1" :index="index" />
</slot>
</ULink>
</li>

View File

@@ -36,7 +36,7 @@ import { computed } from 'vue'
import { useForwardProps } from 'radix-vue'
import { reactiveOmit } from '@vueuse/core'
import { useComponentIcons } from '#imports'
import { UIcon, ULink } from '#components'
import { UIcon, UAvatar, ULink } from '#components'
const props = defineProps<ButtonProps>()
const slots = defineSlots<ButtonSlots>()
@@ -44,7 +44,7 @@ const slots = defineSlots<ButtonSlots>()
const linkProps = useForwardProps(reactiveOmit(props, 'type', 'label', 'color', 'variant', 'size', 'icon', 'leading', 'leadingIcon', 'trailing', 'trailingIcon', 'loading', 'loadingIcon', 'square', 'block', 'disabled', 'truncate', 'class', 'ui'))
// const { size, rounded } = useInjectButtonGroup({ ui, props })
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName, avatarSize } = useComponentIcons<ButtonProps>(props)
const ui = computed(() => tv({ extend: button, slots: props.ui })({
color: props.color,
@@ -63,6 +63,7 @@ const ui = computed(() => tv({ extend: button, slots: props.ui })({
<ULink :type="type" :disabled="disabled || loading" :class="ui.base({ class: props.class })" v-bind="linkProps" raw>
<slot name="leading">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon()" />
<UAvatar v-else-if="avatar" :size="avatarSize" v-bind="avatar" :class="ui.leadingAvatar()" />
</slot>
<span v-if="label || $slots.default" :class="ui.label()">

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { ComboboxRootProps, ComboboxRootEmits, ComboboxItemProps } from 'radix-vue'
import type { FuseResult } from 'fuse.js'
import type { AppConfig } from '@nuxt/schema'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import _appConfig from '#build/app.config'
@@ -28,12 +29,13 @@ export interface CommandPaletteItem extends Pick<ComboboxItemProps, 'disabled'>
export interface CommandPaletteGroup<T> {
id: string
label?: string
slot?: string
items?: T[]
/** The icon displayed when an item is highlighted. */
highlightedIcon?: string
}
export interface CommandPaletteProps<G, T> extends Pick<ComboboxRootProps, 'as' | 'multiple' | 'disabled' | 'modelValue'>, Omit<UseComponentIconsProps, 'leading' | 'trailing' | 'icon'> {
export interface CommandPaletteProps<G, T> extends Pick<ComboboxRootProps, 'as' | 'multiple' | 'disabled' | 'modelValue' | 'defaultValue'>, Omit<UseComponentIconsProps, 'leading' | 'trailing' | 'icon' | 'avatar'> {
/**
* The icon displayed in the input.
* @defaultValue `appConfig.ui.icons.search`
@@ -59,19 +61,19 @@ export type CommandPaletteEmits<T> = {
type SlotProps<T> = (props: { item: T, index: number }) => any
export type CommandPaletteSlots<T extends { slot?: string }> = {
empty(props: { searchTerm: string }): any
close(): any
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
item: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
export type CommandPaletteSlots<G extends { slot?: string }, T extends { slot?: string }> = {
'empty'(props: { searchTerm?: string }): any
'close'(): any
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
} & DynamicSlots<G, SlotProps<T>> & DynamicSlots<T, SlotProps<T>>
</script>
<script setup lang="ts" generic="G extends CommandPaletteGroup<T>, T extends CommandPaletteItem">
import { computed } from 'vue'
import { ComboboxRoot, ComboboxInput, ComboboxPortal, ComboboxContent, ComboboxEmpty, ComboboxViewport, ComboboxGroup, ComboboxLabel, ComboboxItem, ComboboxItemIndicator, useForwardPropsEmits } from 'radix-vue'
import { ComboboxRoot, ComboboxInput, ComboboxPortal, ComboboxContent, ComboboxEmpty, ComboboxViewport, ComboboxGroup, ComboboxLabel, ComboboxItem, ComboboxItemIndicator, useForwardProps, useForwardPropsEmits } from 'radix-vue'
import { defu } from 'defu'
import { reactivePick } from '@vueuse/core'
import { useFuse } from '@vueuse/integrations/useFuse'
@@ -85,12 +87,13 @@ const props = withDefaults(defineProps<CommandPaletteProps<G, T>>(), {
placeholder: 'Type a command or search...'
})
const emits = defineEmits<CommandPaletteEmits<T>>()
defineSlots<CommandPaletteSlots<T>>()
defineSlots<CommandPaletteSlots<G, T>>()
const searchTerm = defineModel<string>('searchTerm', { default: '' })
const appConfig = useAppConfig()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'disabled', 'multiple', 'modelValue'), emits)
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'disabled', 'multiple', 'modelValue', 'defaultValue'), emits)
const inputProps = useForwardProps(reactivePick(props, 'loading', 'loadingIcon', 'placeholder'))
const ui = computed(() => tv({ extend: commandPalette, slots: props.ui })())
@@ -105,14 +108,14 @@ const fuse = computed(() => defu({}, props.fuse, {
const items = computed(() => props.groups?.flatMap(group => group.items?.map(item => ({ ...item, group: group.id })) || []) || [])
const { results: fuseResults } = useFuse(searchTerm, items, fuse)
const { results: fuseResults } = useFuse<T>(searchTerm, items, fuse)
const groups = computed(() => {
if (!fuseResults.value?.length) {
return []
}
const groups: Record<string, T[]> = fuseResults.value.reduce((acc, result) => {
const groups: Record<string, (T & { matches: FuseResult<T>['matches'] })[]> = fuseResults.value.reduce((acc, result) => {
const { item, matches } = result
if (!item.group) {
return acc
@@ -126,15 +129,12 @@ const groups = computed(() => {
return Object.entries(groups).map(([id, items]) => {
const group = props.groups?.find(group => group.id === id)
if (!group) {
return
}
return {
...group,
items: items.slice(0, fuse.value.resultLimit)
}
}).filter(Boolean)
})
})
</script>
@@ -145,10 +145,9 @@ const groups = computed(() => {
<UInput
variant="none"
autofocus
size="md"
v-bind="inputProps"
:icon="icon || appConfig.ui.icons.search"
:loading="loading"
:loading-icon="loadingIcon"
:placeholder="placeholder"
:class="ui.input()"
>
<template v-if="close || $slots.close" #trailing>
@@ -179,54 +178,48 @@ const groups = computed(() => {
<ComboboxViewport :class="ui.viewport()">
<ComboboxGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group()">
<ComboboxLabel v-if="group!.label" :class="ui.label()">
{{ group!.label }}
<ComboboxLabel v-if="group.label" :class="ui.label()">
{{ group.label }}
</ComboboxLabel>
<ComboboxItem
v-for="(item, index) in group!.items"
v-for="(item, index) in group.items"
:key="`group-${groupIndex}-${index}`"
:value="omit(item, ['matches' as any, 'group' as any, 'select'])"
:disabled="item.disabled"
:class="ui.item()"
@select="item.select"
>
<slot :name="item.slot || 'item'" :item="item" :index="index">
<slot :name="`${group.id}-leading`" :item="item" :index="index">
<slot name="leading" :item="item" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.itemLeadingAvatar()" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon()" />
<UChip
v-else-if="item.chip"
size="md"
inset
standalone
v-bind="item.chip"
:class="ui.itemLeadingChip()"
/>
</slot>
<slot :name="item.slot || group.slot || 'item'" :item="item" :index="index">
<slot :name="item.slot ? `${item.slot}-leading` : group.slot ? `${group.slot}-leading` : `item-leading`" :item="item" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.itemLeadingAvatar()" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon()" />
<UChip
v-else-if="item.chip"
size="md"
inset
standalone
v-bind="item.chip"
:class="ui.itemLeadingChip()"
/>
</slot>
<span v-if="item.label || $slots.label || $slots[`${group.id}-label`]" :class="ui.itemLabel()">
<slot :name="`${group.id}-label`" :item="item" :index="index">
<slot name="label" :item="item" :index="index">
<span v-if="item.prefix" :class="ui.itemLabelPrefix()">{{ item.prefix }}</span>
<span v-if="item.label || $slots[item.slot ? `${item.slot}-label` : group.slot ? `${group.slot}-label` : `item-label`]" :class="ui.itemLabel()">
<slot :name="item.slot ? `${item.slot}-label` : group.slot ? `${group.slot}-label` : `item-label`" :item="item" :index="index">
<span v-if="item.prefix" :class="ui.itemLabelPrefix()">{{ item.prefix }}</span>
<span :class="ui.itemLabelBase()" v-html="highlight(item, searchTerm, 'label') || item.label" />
<span :class="ui.itemLabelBase()" v-html="highlight<T>(item, searchTerm, 'label') || item.label" />
<span :class="ui.itemLabelSuffix()" v-html="highlight(item, searchTerm, undefined, ['label']) || item.suffix" />
</slot>
<span :class="ui.itemLabelSuffix()" v-html="highlight<T>(item, searchTerm, undefined, ['label']) || item.suffix" />
</slot>
</span>
<span :class="ui.itemTrailing()">
<slot :name="`${group.id}-trailing`" :item="item" :index="index">
<slot name="trailing" :item="item" :index="index">
<span v-if="item.kbds?.length" :class="ui.itemTrailingKbds()">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" size="md" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span>
<UIcon v-else-if="group.highlightedIcon" :name="group.highlightedIcon" :class="ui.itemTrailingHighlightedIcon()" />
</slot>
<slot :name="item.slot ? `${item.slot}-trailing` : group.slot ? `${group.slot}-trailing` : `item-trailing`" :item="item" :index="index">
<span v-if="item.kbds?.length" :class="ui.itemTrailingKbds()">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" size="md" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span>
<UIcon v-else-if="group.highlightedIcon" :name="group.highlightedIcon" :class="ui.itemTrailingHighlightedIcon()" />
</slot>
<ComboboxItemIndicator as-child>

View File

@@ -42,11 +42,11 @@ export interface ContextMenuEmits extends ContextMenuRootEmits {}
type SlotProps<T> = (props: { item: T, active?: boolean, index: number }) => any
export type ContextMenuSlots<T extends { slot?: string }> = {
default(): any
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
item: SlotProps<T>
'default'(): any
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
</script>

View File

@@ -44,21 +44,21 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
<template>
<DefineItemTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="item" :index="index">
<slot name="leading" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ active })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ active })" />
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ active })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ active })" />
</slot>
<span v-if="item.label || $slots.label" :class="ui.linkLabel()">
<slot name="label" :item="item" :active="active" :index="index">
<span v-if="item.label || $slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel()">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="item" :active="active" :index="index">
{{ item.label }}
</slot>
</span>
<span v-if="$slots.trailing || item.children?.length || item.kbds?.length" :class="ui.linkTrailing()">
<slot name="trailing" :item="item" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.linkTrailingIcon()" />
<span v-else-if="item.kbds?.length" :class="ui.linkTrailingKbds()">
<span v-if="item.children?.length || item.kbds?.length || $slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :class="ui.itemTrailing()">
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="item" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon()" />
<span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds()">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" size="md" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span>
</slot>
@@ -82,7 +82,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
:open="item.open"
:default-open="item.defaultOpen"
:text-value="item.label"
:class="ui.link()"
:class="ui.item()"
>
<ReuseItemTemplate :item="item" :index="index" />
</ContextMenu.SubTrigger>
@@ -103,7 +103,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
</ContextMenu.Sub>
<ContextMenu.Item v-else as-child :disabled="item.disabled" :text-value="item.label" @select="item.select">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit((item as ContextMenuItem), ['label', 'icon', 'avatar', 'content', 'kbds', 'slot', 'open', 'defaultOpen', 'select', 'children', 'type'])" custom>
<ULinkBase v-bind="slotProps" :class="ui.link({ active })">
<ULinkBase v-bind="slotProps" :class="ui.item({ active })">
<ReuseItemTemplate :item="item" :active="active" :index="index" />
</ULinkBase>
</ULink>

View File

@@ -43,11 +43,11 @@ export interface DropdownMenuEmits extends DropdownMenuRootEmits {}
type SlotProps<T> = (props: { item: T, active?: boolean, index: number }) => any
export type DropdownMenuSlots<T extends { slot?: string }> = {
default(): any
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
item: SlotProps<T>
'default'(): any
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
</script>

View File

@@ -44,21 +44,21 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
<template>
<DefineItemTemplate v-slot="{ item, active, index }">
<slot :name="item.slot || 'item'" :item="item" :index="index">
<slot name="leading" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ active })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ active })" />
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ active })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ active })" />
</slot>
<span v-if="item.label || $slots.label" :class="ui.linkLabel()">
<slot name="label" :item="item" :active="active" :index="index">
<span v-if="item.label || $slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel()">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="item" :active="active" :index="index">
{{ item.label }}
</slot>
</span>
<span v-if="$slots.trailing || item.children?.length || item.kbds?.length" :class="ui.linkTrailing()">
<slot name="trailing" :item="item" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.linkTrailingIcon()" />
<span v-else-if="item.kbds?.length" :class="ui.linkTrailingKbds()">
<span v-if="item.children?.length || item.kbds?.length || $slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :class="ui.itemTrailing()">
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="item" :active="active" :index="index">
<UIcon v-if="item.children?.length" :name="appConfig.ui.icons.chevronRight" :class="ui.itemTrailingIcon()" />
<span v-else-if="item.kbds?.length" :class="ui.itemTrailingKbds()">
<UKbd v-for="(kbd, kbdIndex) in item.kbds" :key="kbdIndex" size="md" v-bind="typeof kbd === 'string' ? { value: kbd } : kbd" />
</span>
</slot>
@@ -82,7 +82,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
:open="item.open"
:default-open="item.defaultOpen"
:text-value="item.label"
:class="ui.link()"
:class="ui.item()"
>
<ReuseItemTemplate :item="item" :index="index" />
</DropdownMenu.SubTrigger>
@@ -106,7 +106,7 @@ const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0
</DropdownMenu.Sub>
<DropdownMenu.Item v-else as-child :disabled="item.disabled" :text-value="item.label" @select="item.select">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit((item as DropdownMenuItem), ['label', 'icon', 'avatar', 'content', 'kbds', 'slot', 'open', 'defaultOpen', 'select', 'children', 'type'])" custom>
<ULinkBase v-bind="slotProps" :class="ui.link({ active })">
<ULinkBase v-bind="slotProps" :class="ui.item({ active })">
<ReuseItemTemplate :item="item" :active="active" :index="index" />
</ULinkBase>
</ULink>

View File

@@ -39,13 +39,14 @@ export interface InputEmits {
export interface InputSlots {
leading(): any
default(): any
trailing(): any
trailing(props: { iconClass: string }): any
}
</script>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useComponentIcons, useFormField } from '#imports'
import { UIcon, UAvatar } from '#components'
import { looseToNumber } from '#ui/utils'
defineOptions({ inheritAttrs: false })
@@ -55,12 +56,12 @@ const props = withDefaults(defineProps<InputProps>(), {
autofocusDelay: 100
})
const emits = defineEmits<InputEmits>()
defineSlots<InputSlots>()
const slots = defineSlots<InputSlots>()
const [modelValue, modelModifiers] = defineModel<string | number>()
const { emitFormBlur, emitFormInput, size, color, id, name, disabled } = useFormField<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName, avatarSize } = useComponentIcons<InputProps>(props)
// const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props })
// const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value)
@@ -71,8 +72,8 @@ const ui = computed(() => tv({ extend: input, slots: props.ui })({
variant: props.variant,
size: size?.value,
loading: props.loading,
leading: isLeading.value,
trailing: isTrailing.value
leading: isLeading.value || !!slots.leading,
trailing: isTrailing.value || !!slots.trailing
}))
const inputRef = ref<HTMLInputElement | null>(null)
@@ -139,6 +140,7 @@ onMounted(() => {
:placeholder="placeholder"
:class="ui.base()"
:disabled="disabled"
:required="required"
v-bind="$attrs"
@input="onInput"
@blur="onBlur"
@@ -147,15 +149,16 @@ onMounted(() => {
<slot />
<span v-if="(isLeading && leadingIconName) || $slots.leading" :class="ui.leading()">
<span v-if="isLeading || $slots.leading" :class="ui.leading()">
<slot name="leading">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="ui.leadingIcon()" />
<UAvatar v-if="avatar" :size="avatarSize" v-bind="avatar" :class="ui.leadingAvatar()" />
<UIcon v-else-if="leadingIconName" :name="leadingIconName" :class="ui.leadingIcon()" />
</slot>
</span>
<span v-if="(isTrailing && trailingIconName) || $slots.trailing" :class="ui.trailing()">
<slot name="trailing">
<UIcon v-if="isTrailing && trailingIconName" :name="trailingIconName" :class="ui.trailingIcon()" />
<span v-if="isTrailing || $slots.trailing" :class="ui.trailing()">
<slot name="trailing" :icon-class="ui.trailingIcon()">
<UIcon v-if="trailingIconName" :name="trailingIconName" :class="ui.trailingIcon()" />
</slot>
</span>
</div>

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import { tv } from 'tailwind-variants'
import type { ComboboxRootProps, ComboboxRootEmits, ComboboxContentProps, ComboboxItemProps, ComboboxArrowProps } from 'radix-vue'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/input-menu'
import type { UseComponentIconsProps } from '#ui/composables/useComponentIcons'
import type { AvatarProps, ChipProps, InputProps } from '#ui/types'
type AcceptableValue = string | number | boolean | Record<string, any>
type ArrayOrWrapped<T> = T extends any[] ? T : Array<T>
const appConfig = _appConfig as AppConfig & { ui: { inputMenu: Partial<typeof theme> } }
const inputMenu = tv({ extend: tv(theme), ...(appConfig.ui?.inputMenu || {}) })
export interface InputMenuItem extends Pick<ComboboxItemProps, 'disabled'> {
label?: string
icon?: string
avatar?: AvatarProps
chip?: ChipProps
/**
* The item type.
* @defaultValue `'item'`
*/
type?: 'label' | 'separator' | 'item'
}
export interface InputMenuProps<T> extends Omit<ComboboxRootProps, 'asChild' | 'dir' | 'filterFunction' | 'displayValue' | 'multiple'>, Omit<UseComponentIconsProps, 'leading' | 'trailing' | 'trailingIcon'> {
/**
* The icon displayed in the input.
* @defaultValue `appConfig.ui.icons.chevronDown`
*/
trailingIcon?: string
/**
* The icon displayed when an item is selected.
* @defaultValue `appConfig.ui.icons.check`
*/
selectedIcon?: string
placeholder?: InputProps['placeholder']
required?: InputProps['required']
avatar?: InputProps['avatar']
color?: InputProps['color']
variant?: InputProps['variant']
size?: InputProps['size']
content?: Omit<ComboboxContentProps, 'asChild' | 'forceMount'>
arrow?: boolean | Omit<ComboboxArrowProps, 'asChild'>
portal?: boolean
/**
* Whether to filter items or not, can be an array of fields to filter.
* When `false`, items will not be filtered which is useful for custom filtering.
* @defaultValue `['label']`
*/
filter?: boolean | string[]
items?: T[] | T[][]
class?: any
ui?: Partial<typeof inputMenu.slots>
}
export type InputMenuEmits<T> = ComboboxRootEmits<T>
type SlotProps<T> = (props: { item: T, index: number }) => any
export type InputMenuSlots<T> = {
'leading'(): any
'empty'(props: { searchTerm?: string }): any
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
}
</script>
<script setup lang="ts" generic="T extends InputMenuItem | AcceptableValue">
import { computed, toRef } from 'vue'
import { ComboboxRoot, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, useForwardProps, useForwardPropsEmits } from 'radix-vue'
import { defu } from 'defu'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { UIcon, UChip, UAvatar } from '#components'
import { get } from '#ui/utils'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputMenuProps<T>>(), {
portal: true,
filter: () => ['label']
})
const emits = defineEmits<InputMenuEmits<T>>()
defineSlots<InputMenuSlots<T>>()
const appConfig = useAppConfig()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'open', 'defaultOpen', 'disabled', 'name'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, position: 'popper' }) as ComboboxContentProps)
const inputProps = useForwardProps(reactivePick(props, 'name', 'loading', 'loadingIcon', 'placeholder', 'required', 'color', 'variant', 'size'))
const ui = computed(() => tv({ extend: inputMenu, slots: props.ui })())
function displayValue(val: AcceptableValue) {
if (typeof val === 'object') {
return val.label
}
return String(val)
}
function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: string): ArrayOrWrapped<AcceptableValue> {
if (props.filter === false) {
return items
}
const fields = Array.isArray(props.filter) ? props.filter : ['label']
return items.filter((item) => {
if (typeof item !== 'object') {
return String(item).search(new RegExp(searchTerm, 'i')) !== -1
}
return fields.some((field) => {
const child = get(item, field)
return child !== null && child !== undefined && String(child).search(new RegExp(searchTerm, 'i')) !== -1
})
}) as ArrayOrWrapped<T>
}
const groups = computed(() => props.items?.length ? (Array.isArray(props.items[0]) ? props.items : [props.items]) as InputMenuItem[][] : [])
</script>
<template>
<ComboboxRoot v-slot="{ modelValue }" v-bind="rootProps" :display-value="displayValue" :filter-function="filterFunction" :class="ui.root({ class: props.class })">
<ComboboxAnchor as-child>
<ComboboxInput as-child>
<UInput v-bind="{ ...inputProps, ...$attrs }" :icon="(modelValue as InputMenuItem)?.icon || icon" :avatar="(modelValue as InputMenuItem)?.avatar || avatar" :class="ui.input()">
<template v-if="$slots.leading" #leading>
<slot name="leading" />
</template>
<template #trailing="{ iconClass }">
<ComboboxTrigger :class="ui.trigger()">
<UIcon :name="trailingIcon || appConfig.ui.icons.chevronDown" :class="iconClass" />
</ComboboxTrigger>
</template>
</UInput>
</ComboboxInput>
</ComboboxAnchor>
<ComboboxPortal :disabled="!portal">
<ComboboxContent :class="ui.content()" v-bind="contentProps">
<ComboboxEmpty :class="ui.empty()">
<slot name="empty" :search-term="searchTerm">
{{ searchTerm ? `No results for ${searchTerm}` : 'No results' }}
</slot>
</ComboboxEmpty>
<ComboboxViewport :class="ui.viewport()">
<ComboboxGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group()">
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
<ComboboxLabel v-if="item?.type === 'label'" :class="ui.label()">
{{ item.label }}
</ComboboxLabel>
<ComboboxSeparator v-else-if="item?.type === 'separator'" :class="ui.separator()" />
<ComboboxItem
v-else
:class="ui.item()"
:disabled="item.disabled"
:value="item"
>
<slot name="item" :item="(item as T)" :index="index">
<slot name="item-leading" :item="(item as T)" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.itemLeadingAvatar()" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon()" />
<UChip
v-else-if="item.chip"
size="md"
inset
standalone
v-bind="item.chip"
:class="ui.itemLeadingChip()"
/>
</slot>
<span :class="ui.itemLabel()">
<slot name="item-label" :item="(item as T)" :index="index">
{{ displayValue(item as T) }}
</slot>
</span>
<span :class="ui.itemTrailing()">
<slot name="item-trailing" :item="(item as T)" :index="index" />
<ComboboxItemIndicator as-child>
<UIcon :name="selectedIcon || appConfig.ui.icons.check" :class="ui.itemTrailingSelectedIcon()" />
</ComboboxItemIndicator>
</span>
</slot>
</ComboboxItem>
</template>
</ComboboxGroup>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</template>

View File

@@ -32,10 +32,10 @@ export interface NavigationMenuEmits extends NavigationMenuRootEmits {}
type SlotProps<T> = (props: { item: T, index: number, active?: boolean }) => any
export type NavigationMenuSlots<T extends { slot?: string }> = {
leading: SlotProps<T>
label: SlotProps<T>
trailing: SlotProps<T>
item: SlotProps<T>
'item': SlotProps<T>
'item-leading': SlotProps<T>
'item-label': SlotProps<T>
'item-trailing': SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
</script>
@@ -61,30 +61,30 @@ const lists = computed(() => props.items?.length ? (Array.isArray(props.items[0]
<NavigationMenuRoot v-bind="rootProps" :class="ui.root({ class: props.class })">
<template v-for="(list, listIndex) in lists" :key="`list-${listIndex}`">
<NavigationMenuList :class="ui.list()">
<NavigationMenuItem v-for="(item, index) in list" :key="`list-${listIndex}-${index}`" :value="item.value || String(index)" :class="ui.item()">
<NavigationMenuItem v-for="(item, index) in list" :key="`list-${listIndex}-${index}`" :value="item.value || String(index)" :class="ui.itemWrapper()">
<ULink v-slot="{ active, ...slotProps }" v-bind="omit(item, ['label', 'value', 'icon', 'avatar', 'badge', 'slot', 'select'])" custom>
<NavigationMenuLink as-child :active="active" @select="item.select">
<ULinkBase v-bind="slotProps" :class="ui.link({ active, disabled: !!item.disabled })">
<ULinkBase v-bind="slotProps" :class="ui.item({ active, disabled: !!item.disabled })">
<slot :name="item.slot || 'item'" :item="item" :index="index">
<slot name="leading" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.linkLeadingAvatar({ active, disabled: !!item.disabled })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.linkLeadingIcon({ active, disabled: !!item.disabled })" />
<slot :name="item.slot ? `${item.slot}-leading`: 'item-leading'" :item="item" :active="active" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.itemLeadingAvatar({ active, disabled: !!item.disabled })" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.itemLeadingIcon({ active, disabled: !!item.disabled })" />
</slot>
<span v-if="item.label || $slots.label" :class="ui.linkLabel()">
<slot name="label" :item="item" :active="active" :index="index">
<span v-if="item.label || $slots[item.slot ? `${item.slot}-label`: 'item-label']" :class="ui.itemLabel()">
<slot :name="item.slot ? `${item.slot}-label`: 'item-label'" :item="item" :active="active" :index="index">
{{ item.label }}
</slot>
</span>
<span v-if="$slots.trailing || item.badge" :class="ui.linkTrailing()">
<slot name="trailing" :item="item" :active="active" :index="index">
<span v-if="item.badge || $slots[item.slot ? `${item.slot}-trailing`: 'item-trailing']" :class="ui.itemTrailing()">
<slot :name="item.slot ? `${item.slot}-trailing`: 'item-trailing'" :item="item" :active="active" :index="index">
<UBadge
v-if="item.badge"
color="white"
size="sm"
v-bind="(typeof item.badge === 'string' || typeof item.badge === 'number') ? { label: item.badge } : item.badge"
:class="ui.linkTrailingBadge()"
:class="ui.itemTrailingBadge()"
/>
</slot>
</span>

View File

@@ -31,9 +31,8 @@ export interface TabsEmits extends TabsRootEmits {}
type SlotProps<T> = (props: { item: T, index: number }) => any
export type TabsSlots<T extends { slot?: string }> = {
default: SlotProps<T>
leading: SlotProps<T>
label: SlotProps<T>
default: SlotProps<T>
trailing: SlotProps<T>
content: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
@@ -64,18 +63,16 @@ const ui = computed(() => tv({ extend: tabs, slots: props.ui })({ orientation: p
<TabsIndicator :class="ui.indicator()" />
<TabsTrigger v-for="(item, index) of items" :key="index" :value="item.value || String(index)" :disabled="item.disabled" :class="ui.trigger()">
<slot :item="item" :index="index">
<slot name="leading" :item="item" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.leadingAvatar()" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.leadingIcon()" />
</slot>
<span v-if="item.label || $slots.label" :class="ui.label()">
<slot name="label" :item="item" :index="index">{{ item.label }}</slot>
</span>
<slot name="trailing" :item="item" :index="index" />
<slot name="leading" :item="item" :index="index">
<UAvatar v-if="item.avatar" size="2xs" v-bind="item.avatar" :class="ui.leadingAvatar()" />
<UIcon v-else-if="item.icon" :name="item.icon" :class="ui.leadingIcon()" />
</slot>
<span v-if="item.label || $slots.default" :class="ui.label()">
<slot :item="item" :index="index">{{ item.label }}</slot>
</span>
<slot name="trailing" :item="item" :index="index" />
</TabsTrigger>
</TabsList>

View File

@@ -1,7 +1,12 @@
import { computed } from 'vue'
import { useAppConfig } from '#imports'
import type { AvatarProps } from '#ui/types'
import type { GetObjectField } from '#ui/types/utils'
export interface UseComponentIconsProps {
/** Display an avatar. */
avatar?: AvatarProps
/** Display an icon. */
icon?: string
/** When `true`, the icon will be displayed on the left side. */
leading?: boolean
@@ -20,10 +25,10 @@ export interface UseComponentIconsProps {
loadingIcon?: string
}
export function useComponentIcons(props: UseComponentIconsProps) {
export function useComponentIcons<T>(props: UseComponentIconsProps & { size: GetObjectField<T, 'size'> }) {
const appConfig = useAppConfig()
const isLeading = computed(() => (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing && !props.trailingIcon) || !!props.leadingIcon)
const isLeading = computed(() => !!props.avatar || (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing && !props.trailingIcon) || !!props.leadingIcon)
const isTrailing = computed(() => (props.icon && props.trailing) || (props.loading && props.trailing) || !!props.trailingIcon)
const leadingIconName = computed(() => {
@@ -41,10 +46,21 @@ export function useComponentIcons(props: UseComponentIconsProps) {
return props.trailingIcon || props.icon
})
const avatarSize = computed<AvatarProps['size']>(() => {
return ({
xs: '3xs' as const,
sm: '3xs' as const,
md: '2xs' as const,
lg: '2xs' as const,
xl: 'xs' as const
})[props.size || 'md']
})
return {
isLeading,
isTrailing,
leadingIconName,
trailingIconName
trailingIconName,
avatarSize
}
}

View File

@@ -19,6 +19,7 @@ export * from '../components/Form.vue'
export * from '../components/FormField.vue'
export * from '../components/Icon.vue'
export * from '../components/Input.vue'
export * from '../components/InputMenu.vue'
export * from '../components/Kbd.vue'
export * from '../components/Link.vue'
export * from '../components/Modal.vue'

View File

@@ -1,4 +1,4 @@
import type { FuseResultMatch } from 'fuse.js'
import type { FuseResult, FuseResultMatch } from 'fuse.js'
function truncateHTMLFromStart(html: string, maxLength: number) {
let truncated = ''
@@ -32,7 +32,7 @@ function truncateHTMLFromStart(html: string, maxLength: number) {
return truncated
}
export function highlight(item: { matches?: FuseResultMatch[] }, searchTerm: string, forceKey?: string, omitKeys?: string[]) {
export function highlight<T>(item: T & { matches?: FuseResult<T>['matches'] }, searchTerm: string, forceKey?: string, omitKeys?: string[]) {
const generateHighlightedText = (value: FuseResultMatch['value'], indices: FuseResultMatch['indices'] = []) => {
value = value || ''
let content = ''

View File

@@ -19,6 +19,27 @@ export function omit<Data extends object, Keys extends keyof Data>(data: Data, k
return result as Omit<Data, Keys>
}
export function get(object: Record<string, any>, path: (string | number)[] | string, defaultValue?: any): any {
if (typeof path === 'string') {
path = path.split('.').map((key) => {
const numKey = Number(key)
return Number.isNaN(numKey) ? key : numKey
})
}
let result: any = object
for (const key of path) {
if (result === undefined || result === null) {
return defaultValue
}
result = result[key]
}
return result !== undefined ? result : defaultValue
}
export function looseToNumber(val: any): any {
const n = Number.parseFloat(val)
return Number.isNaN(n) ? val : n