remove old files

This commit is contained in:
Benjamin Canac
2024-03-27 12:39:55 +01:00
parent f3fec877c5
commit 85c693e3ba
150 changed files with 0 additions and 12276 deletions

View File

@@ -1,285 +0,0 @@
import { omit } from './runtime/utils/lodash'
import { kebabCase, camelCase, upperFirst } from 'scule'
const colorsToExclude = [
'inherit',
'transparent',
'current',
'white',
'black',
'slate',
'gray',
'zinc',
'neutral',
'stone',
'cool'
]
const safelistByComponent = {
alert: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-50`)
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`)
}],
avatar: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`)
}],
badge: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-50`)
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`)
}],
button: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-50`),
variants: ['hover', 'disabled']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-100`),
variants: ['hover']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark', 'dark:disabled']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`),
variants: ['disabled', 'dark:hover']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-600`),
variants: ['hover']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-900`),
variants: ['dark:hover']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-950`),
variants: ['dark', 'dark:hover', 'dark:disabled']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark', 'dark:disabled']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`),
variants: ['dark:hover', 'disabled']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-600`),
variants: ['hover']
}, {
pattern: new RegExp(`outline-(${colorsAsRegex})-400`),
variants: ['dark:focus-visible']
}, {
pattern: new RegExp(`outline-(${colorsAsRegex})-500`),
variants: ['focus-visible']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark:focus-visible']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
variants: ['focus-visible']
}],
input: (colorsAsRegex) => [{
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark', 'dark:focus']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
variants: ['focus']
}],
radio: (colorsAsRegex) => [{
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark:focus-visible']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
variants: ['focus-visible']
}],
checkbox: (colorsAsRegex) => [{
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark:focus-visible']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
variants: ['focus-visible']
}],
toggle: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark:focus-visible']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
variants: ['focus-visible']
}],
range: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-400`),
variants: ['dark:focus-visible']
}, {
pattern: new RegExp(`ring-(${colorsAsRegex})-500`),
variants: ['focus-visible']
}],
progress: (colorsAsRegex) => [{
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}],
meter: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}],
notification: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`)
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}],
chip: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-500`)
}]
}
const safelistComponentAliasesMap = {
'USelect': 'UInput',
'USelectMenu': 'UInput',
'UTextarea': 'UInput',
'URadioGroup': 'URadio',
'UMeterGroup': 'UMeter'
}
const colorsAsRegex = (colors: string[]): string => colors.join('|')
export const excludeColors = (colors: object): string[] => {
return Object.entries(omit(colors, colorsToExclude))
.filter(([, value]) => typeof value === 'object')
.map(([key]) => kebabCase(key))
}
export const generateSafelist = (colors: string[], globalColors) => {
const baseSafelist = Object.keys(safelistByComponent).flatMap(component => safelistByComponent[component](colorsAsRegex(colors)))
// Ensure `red` color is safelisted for form elements so that `error` prop of `UFormGroup` always works
const formsSafelist = ['input', 'radio', 'checkbox', 'toggle', 'range'].flatMap(component => safelistByComponent[component](colorsAsRegex(['red'])))
return [
...baseSafelist,
...formsSafelist,
// Ensure all global colors are safelisted for the Notification (toast.add)
...safelistByComponent['notification'](colorsAsRegex(globalColors)),
// Gray safelist for Avatar & Notification
'bg-gray-500',
'dark:bg-gray-400',
'text-gray-500',
'dark:text-gray-400'
]
}
export const customSafelistExtractor = (prefix, content: string, colors: string[], safelistColors: string[]) => {
const classes: string[] = []
const regex = /<([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)*)\s+(?![^>]*:color\b)[^>]*\bcolor=["']([^"']+)["'][^>]*>/gs
const matches = content.matchAll(regex)
const components = Object.keys(safelistByComponent).map(component => `${prefix}${component.charAt(0).toUpperCase() + component.slice(1)}`)
for (const match of matches) {
const [, component, color] = match
const camelComponent = upperFirst(camelCase(component))
if (!colors.includes(color) || safelistColors.includes(color)) {
continue
}
let name = safelistComponentAliasesMap[camelComponent] ? safelistComponentAliasesMap[camelComponent] : camelComponent
if (!components.includes(name)) {
continue
}
name = name.replace(prefix, '').toLowerCase()
const matchClasses = safelistByComponent[name](color).flatMap(group => {
return ['', ...(group.variants || [])].flatMap(variant => {
const matches = group.pattern.source.match(/\(([^)]+)\)/g)
return matches.map(match => {
const colorOptions = match.substring(1, match.length - 1).split('|')
return colorOptions.map(color => `${variant ? variant + ':' : ''}` + group.pattern.source.replace(match, color))
}).flat()
})
})
classes.push(...matchClasses)
}
return classes
}

View File

@@ -1,319 +0,0 @@
<template>
<div :class="ui.wrapper" v-bind="attrs">
<table :class="[ui.base, ui.divide]">
<thead :class="ui.thead">
<tr :class="ui.tr.base">
<th v-if="modelValue" scope="col" :class="ui.checkbox.padding">
<UCheckbox :model-value="indeterminate || selected.length === rows.length" :indeterminate="indeterminate" aria-label="Select all" @change="onChange" />
</th>
<th v-for="(column, index) in columns" :key="index" scope="col" :class="[ui.th.base, ui.th.padding, ui.th.color, ui.th.font, ui.th.size, column.class]">
<slot :name="`${column.key}-header`" :column="column" :sort="sort" :on-sort="onSort">
<UButton
v-if="column.sortable"
v-bind="{ ...(ui.default.sortButton || {}), ...sortButton }"
:icon="(!sort.column || sort.column !== column.key) ? (sortButton.icon || ui.default.sortButton.icon) : sort.direction === 'asc' ? sortAscIcon : sortDescIcon"
:label="column[columnAttribute]"
@click="onSort(column)"
/>
<span v-else>{{ column[columnAttribute] }}</span>
</slot>
</th>
</tr>
<tr v-if="loading && progress">
<td :colspan="0" :class="ui.progress.wrapper">
<UProgress v-bind="{ ...(ui.default.progress || {}), ...progress }" size="2xs" />
</td>
</tr>
</thead>
<tbody :class="ui.tbody">
<tr v-if="loadingState && loading && !rows.length">
<td :colspan="columns.length + (modelValue ? 1 : 0)">
<slot name="loading-state">
<div :class="ui.loadingState.wrapper">
<UIcon v-if="loadingState.icon" :name="loadingState.icon" :class="ui.loadingState.icon" aria-hidden="true" />
<p :class="ui.loadingState.label">
{{ loadingState.label }}
</p>
</div>
</slot>
</td>
</tr>
<tr v-else-if="emptyState && !rows.length">
<td :colspan="columns.length + (modelValue ? 1 : 0)">
<slot name="empty-state">
<div :class="ui.emptyState.wrapper">
<UIcon v-if="emptyState.icon" :name="emptyState.icon" :class="ui.emptyState.icon" aria-hidden="true" />
<p :class="ui.emptyState.label">
{{ emptyState.label }}
</p>
</div>
</slot>
</td>
</tr>
<template v-else>
<tr v-for="(row, index) in rows" :key="index" :class="[ui.tr.base, isSelected(row) && ui.tr.selected, $attrs.onSelect && ui.tr.active, row?.class]" @click="() => onSelect(row)">
<td v-if="modelValue" :class="ui.checkbox.padding">
<UCheckbox v-model="selected" :value="row" aria-label="Select row" @click.stop />
</td>
<td v-for="(column, subIndex) in columns" :key="subIndex" :class="[ui.td.base, ui.td.padding, ui.td.color, ui.td.font, ui.td.size, row[column.key]?.class]">
<slot :name="`${column.key}-data`" :column="column" :row="row" :index="index" :get-row-data="(defaultValue) => getRowData(row, column.key, defaultValue)">
{{ getRowData(row, column.key) }}
</slot>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, toRaw, toRef } from 'vue'
import type { PropType } from 'vue'
import { upperFirst } from 'scule'
import { defu } from 'defu'
import { useVModel } from '@vueuse/core'
import UIcon from '../elements/Icon.vue'
import UButton from '../elements/Button.vue'
import UProgress from '../elements/Progress.vue'
import UCheckbox from '../forms/Checkbox.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig, get } from '../../utils'
import type { Strategy, Button, ProgressColor, ProgressAnimation } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { table } from '#ui/ui.config'
const config = mergeConfig<typeof table>(appConfig.ui.strategy, appConfig.ui.table, table)
function defaultComparator<T> (a: T, z: T): boolean {
return a === z
}
function defaultSort (a: any, b: any, direction: 'asc' | 'desc') {
if (a === b) {
return 0
}
if (direction === 'asc') {
return a < b ? -1 : 1
} else {
return a > b ? -1 : 1
}
}
export default defineComponent({
components: {
UIcon,
UButton,
UProgress,
UCheckbox
},
inheritAttrs: false,
props: {
modelValue: {
type: Array,
default: null
},
by: {
type: [String, Function],
default: () => defaultComparator
},
rows: {
type: Array as PropType<{ [key: string]: any }[]>,
default: () => []
},
columns: {
type: Array as PropType<{ key: string, sortable?: boolean, sort?: (a: any, b: any, direction: 'asc' | 'desc') => number, direction?: 'asc' | 'desc', class?: string, [key: string]: any }[]>,
default: null
},
columnAttribute: {
type: String,
default: 'label'
},
sort: {
type: Object as PropType<{ column: string, direction: 'asc' | 'desc' }>,
default: () => ({})
},
sortMode: {
type: String as PropType<'manual' | 'auto'>,
default: 'auto'
},
sortButton: {
type: Object as PropType<Button>,
default: () => config.default.sortButton as Button
},
sortAscIcon: {
type: String,
default: () => config.default.sortAscIcon
},
sortDescIcon: {
type: String,
default: () => config.default.sortDescIcon
},
loading: {
type: Boolean,
default: false
},
loadingState: {
type: Object as PropType<{ icon: string, label: string }>,
default: () => config.default.loadingState
},
emptyState: {
type: Object as PropType<{ icon: string, label: string }>,
default: () => config.default.emptyState
},
progress: {
type: Object as PropType<{ color: ProgressColor, animation: ProgressAnimation }>,
default: () => config.default.progress
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'update:sort'],
setup (props, { emit, attrs: $attrs }) {
const { ui, attrs } = useUI('table', toRef(props, 'ui'), config, toRef(props, 'class'))
const columns = computed(() => props.columns ?? Object.keys(props.rows[0] ?? {}).map((key) => ({ key, label: upperFirst(key), sortable: false, class: undefined, sort: defaultSort })))
const sort = useVModel(props, 'sort', emit, { passive: true, defaultValue: defu({}, props.sort, { column: null, direction: 'asc' }) })
const savedSort = { column: sort.value.column, direction: null }
const rows = computed(() => {
if (!sort.value?.column || props.sortMode === 'manual') {
return props.rows
}
const { column, direction } = sort.value
return props.rows.slice().sort((a, b) => {
const aValue = get(a, column)
const bValue = get(b, column)
const sort = columns.value.find((col) => col.key === column)?.sort ?? defaultSort
return sort(aValue, bValue, direction)
})
})
const selected = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const indeterminate = computed(() => selected.value && selected.value.length > 0 && selected.value.length < props.rows.length)
const emptyState = computed(() => {
if (props.emptyState === null) return null
return { ...ui.value.default.emptyState, ...props.emptyState }
})
const loadingState = computed(() => {
if (props.loadingState === null) return null
return { ...ui.value.default.loadingState, ...props.loadingState }
})
function compare (a: any, z: any) {
if (typeof props.by === 'string') {
const property = props.by as unknown as any
return a?.[property] === z?.[property]
}
return props.by(a, z)
}
function isSelected (row) {
if (!props.modelValue) {
return false
}
return selected.value.some((item) => compare(toRaw(item), toRaw(row)))
}
function onSort (column: { key: string, direction?: 'asc' | 'desc' }) {
if (sort.value.column === column.key) {
const direction = !column.direction || column.direction === 'asc' ? 'desc' : 'asc'
if (sort.value.direction === direction) {
sort.value = defu({}, savedSort, { column: null, direction: 'asc' })
} else {
sort.value = { column: sort.value.column, direction: sort.value.direction === 'asc' ? 'desc' : 'asc' }
}
} else {
sort.value = { column: column.key, direction: column.direction || 'asc' }
}
}
function onSelect (row) {
if (!$attrs.onSelect) {
return
}
// @ts-ignore
$attrs.onSelect(row)
}
function selectAllRows () {
props.rows.forEach((row) => {
// If the row is already selected, don't select it again
if (isSelected(row)) {
return
}
// @ts-ignore
selected.value.push(row)
})
}
function onChange (event: any) {
if (event.target.checked) {
selectAllRows()
} else {
selected.value = []
}
}
function getRowData (row: Object, rowKey: string | string[], defaultValue: any = '') {
return get(row, rowKey, defaultValue)
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
sort,
// eslint-disable-next-line vue/no-dupe-keys
columns,
// eslint-disable-next-line vue/no-dupe-keys
rows,
selected,
indeterminate,
// eslint-disable-next-line vue/no-dupe-keys
emptyState,
// eslint-disable-next-line vue/no-dupe-keys
loadingState,
isSelected,
onSort,
onSelect,
onChange,
getRowData
}
}
})
</script>

View File

@@ -1,187 +0,0 @@
<template>
<div :class="ui.wrapper">
<HDisclosure
v-for="(item, index) in items"
v-slot="{ open, close }"
:key="index"
as="div"
:class="ui.container"
:default-open="defaultOpen || item.defaultOpen"
>
<HDisclosureButton
:ref="() => buttonRefs[index] = { open, close }"
as="template"
:disabled="item.disabled"
@click="closeOthers(index, $event)"
@keydown.enter="closeOthers(index, $event)"
@keydown.space="closeOthers(index, $event)"
>
<slot :item="item" :index="index" :open="open" :close="close">
<UButton v-bind="{ ...omit(ui.default, ['openIcon', 'closeIcon']), ...attrs, ...omit(item, ['slot', 'disabled', 'content', 'defaultOpen']) }">
<template #trailing>
<UIcon
:name="!open ? openIcon : closeIcon ? closeIcon : openIcon"
:class="[
open && !closeIcon ? '-rotate-180' : '',
uiButton.icon.size[item.size || uiButton.default.size],
ui.item.icon
]"
/>
</template>
</UButton>
</slot>
</HDisclosureButton>
<Transition
v-bind="ui.transition"
@enter="onEnter"
@after-enter="onAfterEnter"
@before-leave="onBeforeLeave"
@leave="onLeave"
>
<div v-show="open">
<HDisclosurePanel :class="[ui.item.base, ui.item.size, ui.item.color, ui.item.padding]" static>
<slot :name="item.slot || 'item'" :item="item" :index="index" :open="open" :close="close">
{{ item.content }}
</slot>
</HDisclosurePanel>
</div>
</Transition>
</HDisclosure>
</div>
</template>
<script lang="ts">
import { ref, computed, toRef, defineComponent, watch } from 'vue'
import type { PropType } from 'vue'
import { Disclosure as HDisclosure, DisclosureButton as HDisclosureButton, DisclosurePanel as HDisclosurePanel, provideUseId } from '@headlessui/vue'
import UIcon from '../elements/Icon.vue'
import UButton from '../elements/Button.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig, omit } from '../../utils'
import type { AccordionItem, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { accordion, button } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof accordion>(appConfig.ui.strategy, appConfig.ui.accordion, accordion)
const configButton = mergeConfig<typeof button>(appConfig.ui.strategy, appConfig.ui.button, button)
export default defineComponent({
components: {
HDisclosure,
HDisclosureButton,
HDisclosurePanel,
UIcon,
UButton
},
inheritAttrs: false,
props: {
items: {
type: Array as PropType<AccordionItem[]>,
default: () => []
},
defaultOpen: {
type: Boolean,
default: false
},
openIcon: {
type: String,
default: () => config.default.openIcon
},
closeIcon: {
type: String,
default: () => config.default.closeIcon
},
multiple: {
type: Boolean,
default: false
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['open'],
setup (props, { emit }) {
const { ui, attrs } = useUI('accordion', toRef(props, 'ui'), config, toRef(props, 'class'))
const uiButton = computed<typeof configButton>(() => configButton)
const buttonRefs = ref<{ open: boolean, close: (e: EventTarget) => {} }[]>([])
const openedStates = computed(() => buttonRefs.value.map(({ open }) => open))
watch(openedStates, (newValue, oldValue) => {
for (const index in newValue) {
const isOpenBefore = oldValue[index]
const isOpenAfter = newValue[index]
if (!isOpenBefore && isOpenAfter) {
emit('open', index)
}
}
}, { immediate: true })
function closeOthers (currentIndex: number, e: Event) {
if (!props.items[currentIndex].closeOthers && props.multiple) {
return
}
buttonRefs.value.forEach((button) => {
if (button.open) {
button.close(e.target as EventTarget)
}
})
}
function onEnter (_el: Element, done: () => void) {
const el = _el as HTMLElement
el.style.height = '0'
el.offsetHeight // Trigger a reflow, flushing the CSS changes
el.style.height = el.scrollHeight + 'px'
el.addEventListener('transitionend', done, { once: true })
}
function onBeforeLeave (_el: Element) {
const el = _el as HTMLElement
el.style.height = el.scrollHeight + 'px'
el.offsetHeight // Trigger a reflow, flushing the CSS changes
}
function onAfterEnter (_el: Element) {
const el = _el as HTMLElement
el.style.height = 'auto'
}
function onLeave (_el: Element, done: () => void) {
const el = _el as HTMLElement
el.style.height = '0'
el.addEventListener('transitionend', done, { once: true })
}
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
uiButton,
attrs,
buttonRefs,
closeOthers,
omit,
onEnter,
onBeforeLeave,
onAfterEnter,
onLeave
}
}
})
</script>

View File

@@ -1,144 +0,0 @@
<template>
<div :class="alertClass" v-bind="attrs">
<div class="flex" :class="[ui.gap, { 'items-start': (description || $slots.description), 'items-center': !description && !$slots.description }]">
<slot name="icon" :icon="icon">
<UIcon v-if="icon" :name="icon" :ui="ui.icon.base" />
</slot>
<slot name="avatar" :avatar="avatar">
<UAvatar v-if="avatar" v-bind="{ size: ui.avatar.size, ...avatar }" :class="ui.avatar.base" />
</slot>
<div :class="ui.inner">
<p v-if="(title || $slots.title)" :class="ui.title">
<slot name="title" :title="title">
{{ title }}
</slot>
</p>
<p v-if="description || $slots.description" :class="twMerge(ui.description, !(title && $slots.title) && 'mt-0 leading-5')">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
<div v-if="(description || $slots.description) && actions.length" :class="ui.actions">
<UButton v-for="(action, index) of actions" :key="index" v-bind="{ ...(ui.default.actionButton || {}), ...action }" @click.stop="onAction(action)" />
</div>
</div>
<div v-if="closeButton || (!description && !$slots.description && actions.length)" :class="twMerge(ui.actions, 'mt-0')">
<template v-if="!description && !$slots.description && actions.length">
<UButton v-for="(action, index) of actions" :key="index" v-bind="{ ...(ui.default.actionButton || {}), ...action }" @click.stop="onAction(action)" />
</template>
<UButton v-if="closeButton" aria-label="Close" v-bind="{ ...(ui.default.closeButton || {}), ...closeButton }" @click.stop="$emit('close')" />
</div>
</div>
</div>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import UButton from '../elements/Button.vue'
import { useUI } from '../../composables/useUI'
import type { Avatar, Button, AlertColor, AlertVariant, AlertAction, Strategy } from '../../types'
import { mergeConfig } from '../../utils'
// @ts-expect-error
import appConfig from '#build/app.config'
import { alert } from '#ui/ui.config'
const config = mergeConfig<typeof alert>(appConfig.ui.strategy, appConfig.ui.alert, alert)
export default defineComponent({
components: {
UIcon,
UAvatar,
UButton
},
inheritAttrs: false,
props: {
title: {
type: String,
default: null
},
description: {
type: String,
default: null
},
icon: {
type: String,
default: () => config.default.icon
},
avatar: {
type: Object as PropType<Avatar>,
default: null
},
closeButton: {
type: Object as PropType<Button>,
default: () => config.default.closeButton as unknown as Button
},
actions: {
type: Array as PropType<AlertAction[]>,
default: () => []
},
color: {
type: String as PropType<AlertColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
variant: {
type: String as PropType<AlertVariant>,
default: () => config.default.variant,
validator (value: string) {
return [
...Object.keys(config.variant),
...Object.values(config.color).flatMap(value => Object.keys(value))
].includes(value)
}
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['close'],
setup (props) {
const { ui, attrs } = useUI('alert', toRef(props, 'ui'), config)
const alertClass = computed(() => {
const variant = ui.value.color?.[props.color as string]?.[props.variant as string] || ui.value.variant[props.variant]
return twMerge(twJoin(
ui.value.wrapper,
ui.value.rounded,
ui.value.shadow,
ui.value.padding,
variant?.replaceAll('{color}', props.color)
), props.class)
})
function onAction (action: AlertAction) {
if (action.click) {
action.click()
}
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
alertClass,
onAction,
twMerge
}
}
})
</script>

View File

@@ -1,170 +0,0 @@
<template>
<span :class="wrapperClass">
<img
v-if="url && !error"
:class="imgClass"
:alt="alt"
:src="url"
v-bind="attrs"
@error="onError"
>
<span v-else-if="text" :class="ui.text">{{ text }}</span>
<UIcon v-else-if="icon" :name="icon" :class="iconClass" />
<span v-else-if="placeholder" :class="ui.placeholder">{{ placeholder }}</span>
<span v-if="chipColor" :class="chipClass">
{{ chipText }}
</span>
<slot />
</span>
</template>
<script lang="ts">
import { defineComponent, ref, computed, toRef, watch } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { AvatarSize, AvatarChipColor, AvatarChipPosition, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { avatar } from '#ui/ui.config'
const config = mergeConfig<typeof avatar>(appConfig.ui.strategy, appConfig.ui.avatar, avatar)
export default defineComponent({
components: {
UIcon
},
inheritAttrs: false,
props: {
src: {
type: [String, Boolean],
default: null
},
alt: {
type: String,
default: null
},
text: {
type: String,
default: null
},
icon: {
type: String,
default: () => config.default.icon
},
size: {
type: String as PropType<AvatarSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
chipColor: {
type: String as PropType<AvatarChipColor>,
default: () => config.default.chipColor,
validator (value: string) {
return ['gray', ...appConfig.ui.colors].includes(value)
}
},
chipPosition: {
type: String as PropType<AvatarChipPosition>,
default: () => config.default.chipPosition,
validator (value: string) {
return Object.keys(config.chip.position).includes(value)
}
},
chipText: {
type: [String, Number],
default: null
},
imgClass: {
type: String,
default: ''
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('avatar', toRef(props, 'ui'), config)
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 wrapperClass = computed(() => {
return twMerge(twJoin(
ui.value.wrapper,
(error.value || !url.value) && ui.value.background,
ui.value.rounded,
ui.value.size[props.size]
), props.class)
})
const imgClass = computed(() => {
return twMerge(twJoin(
ui.value.rounded,
ui.value.size[props.size]
), props.imgClass)
})
const iconClass = computed(() => {
return twJoin(
ui.value.icon.base,
ui.value.icon.size[props.size]
)
})
const chipClass = computed(() => {
return twJoin(
ui.value.chip.base,
ui.value.chip.size[props.size],
ui.value.chip.position[props.chipPosition],
ui.value.chip.background.replaceAll('{color}', props.chipColor)
)
})
const error = ref(false)
watch(() => props.src, () => {
if (error.value) {
error.value = false
}
})
function onError () {
error.value = true
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
wrapperClass,
// eslint-disable-next-line vue/no-dupe-keys
imgClass,
iconClass,
chipClass,
url,
placeholder,
error,
onError
}
}
})
</script>

View File

@@ -1,73 +0,0 @@
import { h, cloneVNode, computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UAvatar from './Avatar.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig, getSlotsChildren } from '../../utils'
import type { AvatarSize, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { avatar, avatarGroup } from '#ui/ui.config'
const avatarConfig = mergeConfig<typeof avatar>(appConfig.ui.strategy, appConfig.ui.avatar, avatar)
const avatarGroupConfig = mergeConfig<typeof avatarGroup>(appConfig.ui.strategy, appConfig.ui.avatarGroup, avatarGroup)
export default defineComponent({
inheritAttrs: false,
props: {
size: {
type: String as PropType<AvatarSize>,
default: null,
validator (value: string) {
return Object.keys(avatarConfig.size).includes(value)
}
},
max: {
type: Number,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof avatarGroupConfig> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props, { slots }) {
const { ui, attrs } = useUI('avatarGroup', toRef(props, 'ui'), avatarGroupConfig, toRef(props, 'class'))
const children = computed(() => getSlotsChildren(slots))
const max = computed(() => typeof props.max === 'string' ? parseInt(props.max, 10) : props.max)
const clones = computed(() => children.value.map((node, index) => {
const vProps: any = {}
if (!props.max || (max.value && index < max.value)) {
if (props.size) {
vProps.size = props.size
}
vProps.class = node.props.class || ''
vProps.class = twMerge(twJoin(vProps.class, ui.value.ring, ui.value.margin), vProps.class)
return cloneVNode(node, vProps)
}
if (max.value !== undefined && index === max.value) {
return h(UAvatar, {
size: props.size || (avatarConfig.default.size as AvatarSize),
text: `+${children.value.length - max.value}`,
class: twJoin(ui.value.ring, ui.value.margin)
})
}
return null
}).filter(Boolean).reverse())
return () => h('div', { class: ui.value.wrapper, ...attrs.value }, clones.value)
}
})

View File

@@ -1,84 +0,0 @@
<template>
<span :class="badgeClass" v-bind="attrs">
<slot>{{ label }}</slot>
</span>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import { useInjectButtonGroup } from '../../composables/useButtonGroup'
import type { BadgeColor, BadgeSize, BadgeVariant, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { badge } from '#ui/ui.config'
const config = mergeConfig<typeof badge>(appConfig.ui.strategy, appConfig.ui.badge, badge)
export default defineComponent({
inheritAttrs: false,
props: {
size: {
type: String as PropType<BadgeSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<BadgeColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
variant: {
type: String as PropType<BadgeVariant>,
default: () => config.default.variant,
validator (value: string) {
return [
...Object.keys(config.variant),
...Object.values(config.color).flatMap(value => Object.keys(value))
].includes(value)
}
},
label: {
type: [String, Number],
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('badge', toRef(props, 'ui'), config)
const { size, rounded } = useInjectButtonGroup({ ui, props })
const badgeClass = computed(() => {
const variant = ui.value.color?.[props.color as string]?.[props.variant as string] || ui.value.variant[props.variant]
return twMerge(twJoin(
ui.value.base,
ui.value.font,
rounded.value,
ui.value.size[size.value],
variant?.replaceAll('{color}', props.color)
), props.class)
})
return {
attrs,
badgeClass
}
}
})
</script>

View File

@@ -1,212 +0,0 @@
<template>
<ULink :type="type" :disabled="disabled || loading" :class="buttonClass" v-bind="{ ...linkProps, ...attrs }">
<slot name="leading" :disabled="disabled" :loading="loading">
<UIcon v-if="isLeading && leadingIconName" :name="leadingIconName" :class="leadingIconClass" aria-hidden="true" />
</slot>
<slot>
<span v-if="label" :class="[truncate ? ui.truncate : '']">
{{ label }}
</span>
</slot>
<slot name="trailing" :disabled="disabled" :loading="loading">
<UIcon v-if="isTrailing && trailingIconName" :name="trailingIconName" :class="trailingIconClass" aria-hidden="true" />
</slot>
</ULink>
</template>
<script lang="ts">
import { computed, defineComponent, toRef } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import ULink from '../elements/Link.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig, nuxtLinkProps, getNuxtLinkProps } from '../../utils'
import { useInjectButtonGroup } from '../../composables/useButtonGroup'
import type { ButtonColor, ButtonSize, ButtonVariant, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { button } from '#ui/ui.config'
const config = mergeConfig<typeof button>(appConfig.ui.strategy, appConfig.ui.button, button)
export default defineComponent({
components: {
UIcon,
ULink
},
inheritAttrs: false,
props: {
...nuxtLinkProps,
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 as PropType<ButtonSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<ButtonColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
variant: {
type: String as PropType<ButtonVariant>,
default: () => config.default.variant,
validator (value: string) {
return [
...Object.keys(config.variant),
...Object.values(config.color).flatMap(value => Object.keys(value))
].includes(value)
}
},
icon: {
type: String,
default: null
},
loadingIcon: {
type: String,
default: () => config.default.loadingIcon
},
leadingIcon: {
type: String,
default: null
},
trailingIcon: {
type: String,
default: null
},
trailing: {
type: Boolean,
default: false
},
leading: {
type: Boolean,
default: false
},
square: {
type: Boolean,
default: false
},
truncate: {
type: Boolean,
default: false
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props, { slots }) {
const { ui, attrs } = useUI('button', toRef(props, 'ui'), config)
const { size, rounded } = useInjectButtonGroup({ ui, props })
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 twMerge(twJoin(
ui.value.base,
ui.value.font,
rounded.value,
ui.value.size[size.value],
ui.value.gap[size.value],
props.padded && ui.value[isSquare.value ? 'square' : 'padding'][size.value],
variant?.replaceAll('{color}', props.color),
props.block ? ui.value.block : ui.value.inline
), props.class)
})
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 twJoin(
ui.value.icon.base,
ui.value.icon.size[size.value],
props.loading && ui.value.icon.loading
)
})
const trailingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
ui.value.icon.size[size.value],
props.loading && !isLeading.value && ui.value.icon.loading
)
})
const linkProps = computed(() => getNuxtLinkProps(props))
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
isLeading,
isTrailing,
isSquare,
buttonClass,
leadingIconName,
trailingIconName,
leadingIconClass,
trailingIconClass,
linkProps
}
}
})
</script>

View File

@@ -1,61 +0,0 @@
import { h, computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { mergeConfig, getSlotsChildren } from '../../utils'
import { useProvideButtonGroup } from '../../composables/useButtonGroup'
import type { ButtonSize, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { button, buttonGroup } from '#ui/ui.config'
const buttonConfig = mergeConfig<typeof button>(appConfig.ui.strategy, appConfig.ui.button, button)
const buttonGroupConfig = mergeConfig<typeof buttonGroup>(appConfig.ui.strategy, appConfig.ui.buttonGroup, buttonGroup)
export default defineComponent({
name: 'ButtonGroup',
inheritAttrs: false,
props: {
size: {
type: String as PropType<ButtonSize>,
default: null,
validator (value: string) {
return Object.keys(buttonConfig.size).includes(value)
}
},
orientation: {
type: String as PropType<'horizontal' | 'vertical'>,
default: 'horizontal',
validator (value: string) {
return ['horizontal', 'vertical'].includes(value)
}
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof buttonGroupConfig> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props, { slots }) {
const { ui, attrs } = useUI('buttonGroup', toRef(props, 'ui'), buttonGroupConfig)
const children = computed(() => getSlotsChildren(slots))
const wrapperClass = computed(() => {
return twMerge(twJoin(
ui.value.wrapper[props.orientation],
ui.value.rounded,
ui.value.shadow
), props.class)
})
const rounded = computed(() => ui.value.orientation[ui.value.rounded][props.orientation])
useProvideButtonGroup({ orientation: toRef(props, 'orientation'), size: toRef(props, 'size'), ui, rounded })
return () => h('div', { class: wrapperClass.value, ...attrs.value }, children.value)
}
})

View File

@@ -1,187 +0,0 @@
<template>
<div :class="ui.wrapper" v-bind="attrs">
<div ref="carouselRef" :class="ui.container" class="no-scrollbar">
<div
v-for="(item, index) in items"
:key="index"
:class="ui.item"
:role="indicators ? 'tabpanel' : null"
>
<slot :item="item" :index="index" />
</div>
</div>
<div v-if="arrows" :class="ui.arrows.wrapper">
<slot name="prev" :on-click="onClickPrev" :disabled="isFirst">
<UButton
v-if="prevButton"
:disabled="isFirst"
v-bind="{ ...ui.default.prevButton, ...prevButton }"
:class="twMerge(ui.default.prevButton.class, prevButton?.class)"
aria-label="Prev"
@click="onClickPrev"
/>
</slot>
<slot name="next" :on-click="onClickNext" :disabled="isLast">
<UButton
v-if="nextButton"
:disabled="isLast"
v-bind="{ ...ui.default.nextButton, ...nextButton }"
:class="twMerge(ui.default.nextButton.class, nextButton?.class)"
aria-label="Next"
@click="onClickNext"
/>
</slot>
</div>
<div v-if="indicators" role="tablist" :class="ui.indicators.wrapper">
<template v-for="page in pages" :key="page">
<slot name="indicator" :on-click="onClick" :active="page === currentPage" :page="page">
<button
type="button"
role="tab"
:aria-selected="page === currentPage"
:class="[
ui.indicators.base,
page === currentPage ? ui.indicators.active : ui.indicators.inactive
]"
:aria-label="`set slide ${page}`"
@click="onClick(page)"
/>
</slot>
</template>
</div>
</div>
</template>
<script lang="ts">
import { ref, toRef, toRefs, computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge } from 'tailwind-merge'
import { mergeConfig } from '../../utils'
import UButton from '../elements/Button.vue'
import type { Strategy, Button } from '../../types'
import { useUI } from '../../composables/useUI'
import { useCarouselScroll } from '../../composables/useCarouselScroll'
import { useScroll, useResizeObserver, useElementSize } from '@vueuse/core'
// @ts-expect-error
import appConfig from '#build/app.config'
import { carousel } from '#ui/ui.config'
const config = mergeConfig<typeof carousel>(appConfig.ui.strategy, appConfig.ui.carousel, carousel)
export default defineComponent({
components: {
UButton
},
inheritAttrs: false,
props: {
items: {
type: Array as PropType<any[]>,
default: () => []
},
arrows: {
type: Boolean,
default: false
},
indicators: {
type: Boolean,
default: false
},
prevButton: {
type: Object as PropType<Button & { class?: string }>,
default: () => config.default.prevButton as Button & { class?: string }
},
nextButton: {
type: Object as PropType<Button & { class?: string }>,
default: () => config.default.nextButton as Button & { class?: string }
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config & { strategy?: Strategy }>>,
default: undefined
}
},
setup (props, { expose }) {
const { ui, attrs } = useUI('carousel', toRef(props, 'ui'), config, toRef(props, 'class'))
const carouselRef = ref<HTMLElement>()
const itemWidth = ref(0)
const { x, arrivedState } = useScroll(carouselRef, { behavior: 'smooth' })
const { width: carouselWidth } = useElementSize(carouselRef)
const { left: isFirst, right: isLast } = toRefs(arrivedState)
useCarouselScroll(carouselRef)
useResizeObserver(carouselRef, (entries) => {
const [entry] = entries
itemWidth.value = entry?.target?.firstElementChild?.clientWidth || 0
})
const currentPage = computed(() => Math.round(x.value / itemWidth.value) + 1)
const pages = computed(() => {
if (!itemWidth.value) {
return 0
}
return props.items.length - Math.round(carouselWidth.value / itemWidth.value) + 1
})
function onClickNext () {
x.value += itemWidth.value
}
function onClickPrev () {
x.value -= itemWidth.value
}
function onClick (page: number) {
x.value = (page - 1) * itemWidth.value
}
expose({
pages,
page: currentPage,
prev: onClickPrev,
next: onClickNext,
select: onClick
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
isFirst,
isLast,
carouselRef,
pages,
currentPage,
onClickNext,
onClickPrev,
onClick,
twMerge
}
}
})
</script>
<style scoped>
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>

View File

@@ -1,92 +0,0 @@
<template>
<div :class="ui.wrapper" v-bind="attrs">
<slot />
<span v-if="show" :class="chipClass">
<slot name="content">
{{ text }}
</slot>
</span>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, toRef } from 'vue'
import type { PropType } from 'vue'
import { twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { ChipSize, ChipColor, ChipPosition, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { chip } from '#ui/ui.config'
const config = mergeConfig<typeof chip>(appConfig.ui.strategy, appConfig.ui.chip, chip)
export default defineComponent({
inheritAttrs: false,
props: {
size: {
type: String as PropType<ChipSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<ChipColor>,
default: () => config.default.color,
validator (value: string) {
return ['gray', ...appConfig.ui.colors].includes(value)
}
},
position: {
type: String as PropType<ChipPosition>,
default: () => config.default.position,
validator (value: string) {
return Object.keys(config.position).includes(value)
}
},
text: {
type: [String, Number],
default: null
},
inset: {
type: Boolean,
default: () => config.default.inset
},
show: {
type: Boolean,
default: true
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('chip', toRef(props, 'ui'), config, toRef(props, 'class'))
const chipClass = computed(() => {
return twJoin(
ui.value.base,
ui.value.size[props.size],
ui.value.position[props.position],
props.inset ? null : ui.value.translate[props.position],
ui.value.background.replaceAll('{color}', props.color)
)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
chipClass
}
}
})
</script>

View File

@@ -1,290 +0,0 @@
<template>
<!-- eslint-disable-next-line vue/no-template-shadow -->
<HMenu v-slot="{ open }" as="div" :class="ui.wrapper" v-bind="attrs" @mouseleave="onMouseLeave">
<HMenuButton
ref="trigger"
as="div"
:disabled="disabled"
:class="ui.trigger"
role="button"
@mouseenter="onMouseEnter"
@touchstart.passive="onTouchStart"
>
<slot :open="open" :disabled="disabled">
<button :disabled="disabled">
Open
</button>
</slot>
</HMenuButton>
<div v-if="open && items.length" ref="container" :class="[ui.container, ui.width]" :style="containerStyle" @mouseenter="onMouseEnter">
<Transition appear v-bind="ui.transition">
<div>
<div v-if="popper.arrow" data-popper-arrow :class="Object.values(ui.arrow)" />
<HMenuItems :class="[ui.base, ui.divide, ui.ring, ui.rounded, ui.shadow, ui.background, ui.height]" static>
<div v-for="(subItems, index) of items" :key="index" :class="ui.padding">
<NuxtLink v-for="(item, subIndex) of subItems" :key="subIndex" v-slot="{ href, target, rel, navigate, isExternal, isActive }" v-bind="getNuxtLinkProps(item)" custom>
<HMenuItem v-slot="{ active, disabled: itemDisabled, close }" :disabled="item.disabled">
<component
:is="!!href ? 'a' : 'button'"
:href="!itemDisabled ? href : undefined"
:rel="rel"
:target="target"
:class="twMerge(twJoin(ui.item.base, ui.item.padding, ui.item.size, ui.item.rounded, active || isActive ? ui.item.active : ui.item.inactive, itemDisabled && ui.item.disabled), item.class)"
@click="onClick($event, item, { href, navigate, close, isExternal })"
>
<slot :name="item.slot || 'item'" :item="item">
<UIcon v-if="item.icon" :name="item.icon" :class="twMerge(twJoin(ui.item.icon.base, active || isActive ? ui.item.icon.active : ui.item.icon.inactive), item.iconClass)" />
<UAvatar v-else-if="item.avatar" v-bind="{ size: ui.item.avatar.size, ...item.avatar }" :class="ui.item.avatar.base" />
<span :class="twMerge(ui.item.label, item.labelClass)">{{ item.label }}</span>
<span v-if="item.shortcuts?.length" :class="ui.item.shortcuts">
<UKbd v-for="shortcut of item.shortcuts" :key="shortcut">{{ shortcut }}</UKbd>
</span>
</slot>
</component>
</HMenuItem>
</NuxtLink>
</div>
</HMenuItems>
</div>
</Transition>
</div>
</HMenu>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch, toRef, onMounted, resolveComponent } from 'vue'
import type { PropType } from 'vue'
import { Menu as HMenu, MenuButton as HMenuButton, MenuItems as HMenuItems, MenuItem as HMenuItem, provideUseId } from '@headlessui/vue'
import { defu } from 'defu'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import UKbd from '../elements/Kbd.vue'
import { useUI } from '../../composables/useUI'
import { usePopper } from '../../composables/usePopper'
import { mergeConfig, getNuxtLinkProps } from '../../utils'
import type { DropdownItem, PopperOptions, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { dropdown } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof dropdown>(appConfig.ui.strategy, appConfig.ui.dropdown, dropdown)
export default defineComponent({
components: {
HMenu,
HMenuButton,
HMenuItems,
HMenuItem,
UIcon,
UAvatar,
UKbd
},
inheritAttrs: false,
props: {
items: {
type: Array as PropType<DropdownItem[][]>,
default: () => []
},
mode: {
type: String as PropType<'click' | 'hover'>,
default: 'click',
validator: (value: string) => ['click', 'hover'].includes(value)
},
open: {
type: Boolean,
default: undefined
},
disabled: {
type: Boolean,
default: false
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
openDelay: {
type: Number,
default: () => config.default.openDelay
},
closeDelay: {
type: Number,
default: () => config.default.closeDelay
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:open'],
setup (props, { emit }) {
const { ui, attrs } = useUI('dropdown', toRef(props, 'ui'), config, toRef(props, 'class'))
const popper = computed<PopperOptions>(() => defu(props.mode === 'hover' ? { offsetDistance: 0 } : {}, props.popper, ui.value.popper as PopperOptions))
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(() => {
// @ts-expect-error internals
const menuProvides = trigger.value?.$.provides
if (!menuProvides) {
return
}
const menuProvidesSymbols = Object.getOwnPropertySymbols(menuProvides)
menuApi.value = menuProvidesSymbols.length && menuProvides[menuProvidesSymbols[0]]
if (props.open) {
menuApi.value?.openMenu()
}
})
const containerStyle = computed(() => {
if (props.mode !== 'hover') {
return {}
}
const offsetDistance = (props.popper as PopperOptions)?.offsetDistance || (ui.value.popper as PopperOptions)?.offsetDistance || 8
const placement = popper.value.placement?.split('-')[0]
const padding = `${offsetDistance}px`
if (placement === 'top' || placement === 'bottom') {
return {
paddingTop: padding,
paddingBottom: padding
}
} else if (placement === 'left' || placement === 'right') {
return {
paddingLeft: padding,
paddingRight: padding
}
} else {
return {
paddingTop: padding,
paddingBottom: padding,
paddingLeft: padding,
paddingRight: padding
}
}
})
function onTouchStart () {
if (!menuApi.value) {
return
}
if (menuApi.value.menuState === 0) {
menuApi.value.closeMenu()
} else {
menuApi.value.openMenu()
}
}
function onMouseEnter () {
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)
}
function onClick (e, item, { href, navigate, close, isExternal }) {
if (item.click) {
item.click(e)
}
if (href && !isExternal) {
navigate(e)
close()
}
}
watch(() => props.open, (newValue: boolean, oldValue: boolean) => {
if (!menuApi.value) return
if (oldValue === undefined || newValue === oldValue) return
if (newValue) {
menuApi.value.openMenu()
} else {
menuApi.value.closeMenu()
}
})
watch(() => menuApi.value?.menuState, (newValue: number, oldValue: number) => {
if (oldValue === undefined || newValue === oldValue) return
emit('update:open', newValue === 0)
})
const NuxtLink = resolveComponent('NuxtLink')
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
popper,
trigger,
container,
containerStyle,
onTouchStart,
onMouseEnter,
onMouseLeave,
onClick,
getNuxtLinkProps,
twMerge,
twJoin,
NuxtLink
}
}
})
</script>

View File

@@ -1,33 +0,0 @@
<template>
<Icon v-if="dynamic" :name="name" />
<span v-else :class="name" />
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue'
import { useAppConfig } from '#imports'
export default defineComponent({
props: {
name: {
type: String,
required: true
},
dynamic: {
type: Boolean,
default: false
}
},
setup (props) {
const appConfig = useAppConfig()
// @ts-ignore
const dynamic = computed(() => props.dynamic || appConfig.ui?.icons?.dynamic)
return {
// eslint-disable-next-line vue/no-dupe-keys
dynamic
}
}
})
</script>

View File

@@ -1,66 +0,0 @@
<template>
<kbd :class="kbdClass" v-bind="attrs">
<slot>{{ value }}</slot>
</kbd>
</template>
<script lang="ts">
import { toRef, defineComponent, computed } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { KbdSize, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { kbd } from '#ui/ui.config'
const config = mergeConfig<typeof kbd>(appConfig.ui.strategy, appConfig.ui.kbd, kbd)
export default defineComponent({
inheritAttrs: false,
props: {
value: {
type: String,
default: null
},
size: {
type: String as PropType<KbdSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('kbd', toRef(props, 'ui'), config)
const kbdClass = computed(() => {
return twMerge(twJoin(
ui.value.base,
ui.value.size[props.size],
ui.value.padding,
ui.value.rounded,
ui.value.font,
ui.value.background,
ui.value.ring
), props.class)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
kbdClass
}
}
})
</script>

View File

@@ -1,100 +0,0 @@
<template>
<component
:is="as"
v-if="!to"
:type="type"
:disabled="disabled"
v-bind="$attrs"
:class="active ? activeClass : inactiveClass"
>
<slot v-bind="{ isActive: active }" />
</component>
<NuxtLink
v-else
v-slot="{ route, href, target, rel, navigate, isActive, isExactActive, isExternal }"
v-bind="$props"
custom
>
<a
v-bind="$attrs"
:href="!disabled ? href : undefined"
:aria-disabled="disabled ? 'true' : undefined"
:role="disabled ? 'link' : undefined"
:rel="rel"
:target="target"
:class="active !== undefined ? (active ? activeClass : inactiveClass) : resolveLinkClass(route, $route, { isActive, isExactActive })"
@click="(e) => (!isExternal && !disabled) && navigate(e)"
>
<slot v-bind="{ isActive: active !== undefined ? active : (exact ? isExactActive : isActive) }" />
</a>
</NuxtLink>
</template>
<script lang="ts">
import { isEqual } from 'ohash'
import { defineComponent } from 'vue'
import { nuxtLinkProps } from '../../utils'
export default defineComponent({
inheritAttrs: false,
props: {
...nuxtLinkProps,
as: {
type: String,
default: 'button'
},
type: {
type: String,
default: 'button'
},
disabled: {
type: Boolean,
default: null
},
active: {
type: Boolean,
default: undefined
},
exact: {
type: Boolean,
default: false
},
exactQuery: {
type: Boolean,
default: false
},
exactHash: {
type: Boolean,
default: false
},
inactiveClass: {
type: String,
default: undefined
}
},
setup (props) {
function resolveLinkClass (route, $route, { isActive, isExactActive }: { isActive: boolean, isExactActive: boolean }) {
if (props.exactQuery && !isEqual(route.query, $route.query)) {
return props.inactiveClass
}
if (props.exactHash && route.hash !== $route.hash) {
return props.inactiveClass
}
if (props.exact && isExactActive) {
return props.activeClass
}
if (!props.exact && isActive) {
return props.activeClass
}
return props.inactiveClass
}
return {
resolveLinkClass
}
}
})
</script>

View File

@@ -1,191 +0,0 @@
<template>
<div :class="ui.wrapper" v-bind="attrs">
<template v-if="indicator || $slots.indicator">
<slot name="indicator" v-bind="{ percent, value }">
<div :class="indicatorContainerClass" :style="{ width: `${percent}%` }">
<div :class="indicatorClass">
{{ Math.round(percent) }}%
</div>
</div>
</slot>
</template>
<meter
:value="value"
:min="normalizedMin"
:max="normalizedMax"
:class="[meterClass, meterAppearanceClass, meterBarClass]"
/>
<template v-if="label || $slots.label">
<slot name="label" v-bind="{ percent, value }">
<div :class="labelClass">
<UIcon v-if="icon" :name="icon" /> {{ label }}
</div>
</slot>
</template>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, toRef } from 'vue'
import type { SlotsType, PropType } from 'vue'
import { twJoin } from 'tailwind-merge'
import UIcon from './Icon.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Strategy, MeterColor, MeterSize } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { meter } from '#ui/ui.config'
const config = mergeConfig<typeof meter>(appConfig.ui.strategy, appConfig.ui.meter, meter)
export default defineComponent({
components: {
UIcon
},
inheritAttrs: false,
slots: Object as SlotsType<{
indicator?: { percent: number, value: number },
label?: { percent: number, value: number },
}>,
props: {
value: {
type: Number,
default: 0
},
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
indicator: {
type: Boolean,
default: false
},
label: {
type: String,
default: null
},
size: {
type: String as PropType<MeterSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(config.meter.size).includes(value)
}
},
color: {
type: String as PropType<MeterColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
icon: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('meter', toRef(props, 'ui'), config, toRef(props, 'class'))
function clampPercent (value: number, min: number, max: number): number {
if (min == max) {
return value < min ? 0 : 100
}
if (min > max) {
max = [min, min = max][0]
}
const percent = (value - min) / (max - min) * 100
return Math.max(0, Math.min(100, percent))
}
const indicatorContainerClass = computed(() => {
return twJoin(
ui.value.indicator.container
)
})
const indicatorClass = computed(() => {
return twJoin(
ui.value.indicator.text,
ui.value.indicator.size[props.size]
)
})
const meterClass = computed(() => {
return twJoin(
ui.value.meter.base,
ui.value.meter.background,
ui.value.meter.ring,
ui.value.meter.rounded,
ui.value.meter.shadow,
ui.value.color[props.color] ?? ui.value.meter.color.replaceAll('{color}', props.color),
ui.value.meter.size[props.size]
)
})
const meterAppearanceClass = computed(() => {
return twJoin(
ui.value.meter.appearance.inner,
ui.value.meter.appearance.meter,
ui.value.meter.appearance.bar,
ui.value.meter.appearance.value
)
})
const meterBarClass = computed(() => {
return twJoin(
ui.value.meter.bar.transition,
ui.value.meter.bar.ring,
ui.value.meter.bar.rounded,
ui.value.meter.bar.size[props.size]
)
})
const labelClass = computed(() => {
return twJoin(
ui.value.label.base,
ui.value.label.text,
ui.value.color[props.color] ?? ui.value.label.color.replaceAll('{color}', props.color),
ui.value.label.size[props.size]
)
})
const normalizedMin = computed(() => props.min > props.max ? props.max : props.min)
const normalizedMax = computed(() => props.max < props.min ? props.min : props.max)
const percent = computed(() => clampPercent(Number(props.value), normalizedMin.value, normalizedMax.value))
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
indicatorContainerClass,
indicatorClass,
meterClass,
meterAppearanceClass,
meterBarClass,
labelClass,
normalizedMin,
normalizedMax,
percent
}
}
})
</script>

View File

@@ -1,208 +0,0 @@
import { h, cloneVNode, computed, toRef, defineComponent } from 'vue'
import type { ComputedRef, VNode, SlotsType, PropType } from 'vue'
import { twJoin } from 'tailwind-merge'
import UIcon from './Icon.vue'
import Meter from './Meter.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig, getSlotsChildren } from '../../utils'
import type { Strategy, MeterSize } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { meter, meterGroup } from '#ui/ui.config'
const meterConfig = mergeConfig<typeof meter>(appConfig.ui.strategy, appConfig.ui.meter, meter)
const meterGroupConfig = mergeConfig<typeof meterGroup>(appConfig.ui.strategy, appConfig.ui.meterGroup, meterGroup)
export default defineComponent({
components: {
UIcon
},
inheritAttrs: false,
slots: Object as SlotsType<{
default?: typeof Meter[],
indicator?: { percent: number },
}>,
props: {
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
size: {
type: String as PropType<MeterSize>,
default: () => meterConfig.default.size,
validator (value: string) {
return Object.keys(meterConfig.meter.bar.size).includes(value)
}
},
indicator: {
type: Boolean,
default: false
},
icon: {
type: String,
default: () => meterGroupConfig.default.icon
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof meterGroupConfig> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props, { slots }) {
const { ui, attrs } = useUI('meterGroup', toRef(props, 'ui'), meterGroupConfig)
const { ui: uiMeter } = useUI('meter', undefined, meterConfig)
// If there is no children, throw an expressive error.
if (!slots.default) {
throw new Error('Meter Group has no Meter children.')
}
// Normalize the min and max numbers, if these are inversed.
const normalizedMin = computed(() => props.min > props.max ? props.max : props.min)
const normalizedMax = computed(() => props.max < props.min ? props.min : props.max)
const children = computed(() => getSlotsChildren(slots))
const rounded = computed(() => ui.value.orientation[ui.value.rounded])
function clampPercent (value: number, min: number, max: number): number {
if (min == max) {
return value < min ? 0 : 100
}
if (min > max) {
max = [min, min = max][0]
}
const percent = (value - min) / (max - min) * 100
return Math.max(0, Math.min(100, percent))
}
// We have to store the labels outside to preserve reactivity later.
const labels = computed(() => {
return children.value.map(node => node.props.label)
})
const percents = computed(() => {
return children.value.map(node => clampPercent(node.props.value, props.min, props.max))
})
const percent = computed(() => {
return Math.max(0, Math.max(percents.value.reduce((prev, percent) => prev + percent, 0)))
})
const clones: ComputedRef<VNode> = computed(() => children.value.map((node, index) => {
const vProps: any = {}
vProps.style = { width: `${percents.value[index]}%` }
// Normalize the props to be the same on all groups
vProps.size = props.size
vProps.min = normalizedMin.value
vProps.max = normalizedMax.value
// Adjust the style of all meters, so they appear in a row.
vProps.ui = node.props?.ui || {}
vProps.ui.wrapper = node.props?.ui?.wrapper || ''
vProps.ui.wrapper += [
node.props?.ui?.wrapper,
ui.value.background,
ui.value.transition
].filter(Boolean).join(' ')
// Override the background to make the bar appear "full"
vProps.ui.meter = node.props?.ui?.meter || {}
vProps.ui.meter.background = `bg-${node.props.color}-500 dark:bg-${node.props.color}-400`
vProps.ui.meter.rounded = 'rounded-none'
vProps.ui.meter.bar = node.props?.ui?.meter?.bar || {}
if (index === 0) {
vProps.ui.meter.rounded = `${rounded.value.left} rounded-e-none`
}
if (index === children.value.length - 1) {
vProps.ui.meter.rounded = `${rounded.value.right} rounded-s-none`
}
// Move the labels out of the node so these can be checked later
labels.value[index] = node.props.label
const clone = cloneVNode(node, vProps)
// @ts-expect-error
delete(clone.children?.label)
delete(clone.props?.indicator)
delete(clone.props?.label)
return clone
}))
const baseClass = computed(() => {
return twJoin(
ui.value.base,
ui.value.background,
ui.value.rounded,
ui.value.shadow,
uiMeter.value.meter.size[props.size]
)
})
const indicatorContainerClass = computed(() => {
return twJoin(
uiMeter.value.indicator.container
)
})
const indicatorClass = computed(() => {
return twJoin(
uiMeter.value.indicator.text,
uiMeter.value.indicator.size[props.size]
)
})
const vNodeChildren = computed(() => {
const vNodeSlots = [
undefined,
h('div', { class: baseClass.value }, clones.value),
undefined
]
if (props.indicator) {
vNodeSlots[0] = h('div', { class: indicatorContainerClass.value }, [
h('div', { class: indicatorClass.value, style: { width: `${percent.value}%` } }, Math.round(percent.value) + '%')
])
} else if (slots.indicator) {
// @ts-expect-error
vNodeSlots[0] = slots.indicator({ percent: percent.value })
}
vNodeSlots[2] = h('ol', { class: ui.value.list }, labels.value.map((label, key) => {
const labelClass = computed(() => {
return twJoin(
uiMeter.value.label.base,
uiMeter.value.label.text,
uiMeter.value.color[clones.value[key]?.props.color] ?? uiMeter.value.label.color.replaceAll('{color}', clones.value[key]?.props.color ?? uiMeter.value.default.color),
uiMeter.value.label.size[props.size]
)
})
return h('li', { class: labelClass.value }, [
h(UIcon, { name: clones.value[key]?.props.icon ?? props.icon }),
`${label} (${ Math.round(percents.value[key]) }%)`
])
}))
return vNodeSlots
})
return () => h('div', { class: ui.value.wrapper, ...attrs.value }, vNodeChildren.value)
}
})

View File

@@ -1,359 +0,0 @@
<template>
<div :class="ui.wrapper" v-bind="attrs">
<slot v-if="indicator || $slots.indicator" name="indicator" v-bind="{ percent }">
<div v-if="!isSteps" :class="indicatorContainerClass" :style="{ width: `${percent}%` }">
<div :class="indicatorClass">
{{ Math.round(percent) }}%
</div>
</div>
</slot>
<progress :class="progressClass" v-bind="{ value, max: realMax }">
{{ percent !== undefined ? `${Math.round(percent)}%` : undefined }}
</progress>
<div v-if="isSteps" :class="stepsClass">
<div v-for="(step, index) in max" :key="index" :class="stepClasses(index)">
<slot :name="`step-${index}`" v-bind="{ step }">
{{ step }}
</slot>
</div>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, toRef } from 'vue'
import type { PropType } from 'vue'
import { twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Strategy, ProgressSize, ProgressAnimation, ProgressColor } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { progress } from '#ui/ui.config'
const config = mergeConfig<typeof progress>(appConfig.ui.strategy, appConfig.ui.progress, progress)
export default defineComponent({
inheritAttrs: false,
props: {
value: {
type: Number,
default: null
},
max: {
type: [Number, Array<any>],
default: 100
},
indicator: {
type: Boolean,
default: false
},
animation: {
type: String as PropType<ProgressAnimation>,
default: () => config.default.animation,
validator (value: string) {
return Object.keys(config.animation).includes(value)
}
},
size: {
type: String as PropType<ProgressSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(config.progress.size).includes(value)
}
},
color: {
type: String as PropType<ProgressColor>,
default: () => config.default.color,
validator (value: string) {
return appConfig.ui.colors.includes(value)
}
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('progress', toRef(props, 'ui'), config, toRef(props, 'class'))
const indicatorContainerClass = computed(() => {
return twJoin(
ui.value.indicator.container.base,
ui.value.indicator.container.width,
ui.value.indicator.container.transition
)
})
const indicatorClass = computed(() => {
return twJoin(
ui.value.indicator.align,
ui.value.indicator.width,
ui.value.indicator.color,
ui.value.indicator.size[props.size]
)
})
const progressClass = computed(() => {
const classes = [
ui.value.progress.base,
ui.value.progress.width,
ui.value.progress.size[props.size],
ui.value.progress.rounded,
ui.value.progress.track,
ui.value.progress.bar,
// Intermediate class to allow thumb ring or background color (set to `current`) as it's impossible to safelist with arbitrary values
ui.value.progress.color?.replaceAll('{color}', props.color),
ui.value.progress.background,
ui.value.progress.indeterminate.base,
ui.value.progress.indeterminate.rounded
]
if (isIndeterminate.value) {
classes.push(ui.value.animation[props.animation])
}
return twJoin(...classes)
})
const stepsClass = computed(() => {
return twJoin(
ui.value.steps.base,
ui.value.steps.color?.replaceAll('{color}', props.color),
ui.value.steps.size[props.size]
)
})
const stepClass = computed(() => {
return twJoin(
ui.value.step.base,
ui.value.step.align
)
})
const stepActiveClass = computed(() => {
return twJoin(
ui.value.step.active
)
})
const stepFirstClass = computed(() => {
return twJoin(
ui.value.step.first
)
})
function isActive (index: number) {
return index === Number(props.value)
}
function isFirst (index: number) {
return index === 0
}
function stepClasses (index: string | number) {
index = Number(index)
const classes = [stepClass.value]
if (isFirst(index)) {
classes.push(stepFirstClass.value)
}
if (isActive(index)) {
classes.push(stepActiveClass.value)
}
return classes.join(' ')
}
const isIndeterminate = computed(() => props.value === undefined || props.value === null)
const isSteps = computed(() => Array.isArray(props.max))
const realMax = computed(() => {
if (isIndeterminate.value) {
return null
}
if (Array.isArray(props.max)) {
return props.max.length - 1
}
return Number(props.max)
})
const percent = computed(() => {
if (isIndeterminate.value) {
return undefined
}
switch (true) {
case props.value < 0: return 0
case props.value > (realMax.value as number): return 100
default: return (props.value / (realMax.value as number)) * 100
}
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
indicatorContainerClass,
indicatorClass,
progressClass,
stepsClass,
stepClasses,
isIndeterminate,
isSteps,
realMax,
percent
}
}
})
</script>
<style scoped>
/** These styles are required to animate the bar */
progress:indeterminate {
@apply relative;
&:after {
@apply content-[''];
@apply absolute inset-0;
@apply bg-current;
}
&::-webkit-progress-value {
@apply bg-current;
}
&::-moz-progress-bar {
@apply bg-current;
}
&.bar-animation-carousel {
&:after {
animation: carousel 2s ease-in-out infinite;
}
&::-webkit-progress-value {
animation: carousel 2s ease-in-out infinite;
}
&::-moz-progress-bar {
animation: carousel 2s ease-in-out infinite;
}
}
&.bar-animation-carousel-inverse {
&:after {
animation: carousel-inverse 2s ease-in-out infinite;
}
&::-webkit-progress-value {
animation: carousel-inverse 2s ease-in-out infinite;
}
&::-moz-progress-bar {
animation: carousel-inverse 2s ease-in-out infinite;
}
}
&.bar-animation-swing {
&:after {
animation: swing 3s ease-in-out infinite;
}
&::-webkit-progress-value {
animation: swing 3s ease-in-out infinite;
}
&::-moz-progress-bar {
animation: swing 3s ease-in-out infinite;
}
}
&.bar-animation-elastic {
&::after {
animation: elastic 3s ease-in-out infinite;
}
&::-webkit-progress-value {
animation: elastic 3s ease-in-out infinite;
}
&::-moz-progress-bar {
animation: elastic 3s ease-in-out infinite;
}
}
}
@keyframes carousel {
0%,
100% {
width: 50%
}
0% {
transform: translateX(-100%)
}
100% {
transform: translateX(200%)
}
}
@keyframes carousel-inverse {
0%,
100% {
width: 50%
}
0% {
transform: translateX(200%)
}
100% {
transform: translateX(-100%)
}
}
@keyframes swing {
0%,
100% {
width: 50%
}
0%,
100% {
transform: translateX(-25%)
}
50% {
transform: translateX(125%)
}
}
@keyframes elastic {
/* Firefox doesn't do "margin: 0 auto", we have to play with margin-left */
0%,
100% {
width: 50%;
margin-left: 25%;
}
50% {
width: 90%;
margin-left: 5%
}
}</style>

View File

@@ -1,152 +0,0 @@
<template>
<div :class="ui.wrapper" :data-n-ids="attrs['data-n-ids']">
<div :class="ui.container">
<input
:id="inputId"
v-model="toggle"
:name="name"
:required="required"
:value="value"
:disabled="disabled"
:indeterminate="indeterminate"
type="checkbox"
:class="inputClass"
v-bind="attrs"
@change="onChange"
>
</div>
<div v-if="label || $slots.label" :class="ui.inner">
<label :for="inputId" :class="ui.label">
<slot name="label">{{ label }}</slot>
<span v-if="required" :class="ui.required">*</span>
</label>
<p v-if="help" :class="ui.help">
{{ help }}
</p>
</div>
</div>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { useFormGroup } from '../../composables/useFormGroup'
import { mergeConfig } from '../../utils'
import type { Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { checkbox } from '#ui/ui.config'
import colors from '#ui-colors'
import { useId } from '#app'
const config = mergeConfig<typeof checkbox>(appConfig.ui.strategy, appConfig.ui.checkbox, checkbox)
export default defineComponent({
inheritAttrs: false,
props: {
id: {
type: String,
default: () => null
},
value: {
type: [String, Number, Boolean, Object],
default: null
},
modelValue: {
type: [Boolean, Array],
default: null
},
name: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
indeterminate: {
type: Boolean,
default: undefined
},
help: {
type: String,
default: null
},
label: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
color: {
type: String as PropType<typeof colors[number]>,
default: () => config.default.color,
validator (value: string) {
return appConfig.ui.colors.includes(value)
}
},
inputClass: {
type: String,
default: ''
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
const { ui, attrs } = useUI('checkbox', toRef(props, 'ui'), config, toRef(props, 'class'))
const { emitFormChange, color, name, inputId: _inputId } = useFormGroup(props)
const inputId = _inputId.value ?? useId()
const toggle = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const onChange = (event: Event) => {
emit('change', (event.target as HTMLInputElement).value)
emitFormChange()
}
const inputClass = computed(() => {
return twMerge(twJoin(
ui.value.base,
ui.value.form,
ui.value.rounded,
ui.value.background,
ui.value.border,
color.value && ui.value.ring.replaceAll('{color}', color.value),
color.value && ui.value.color.replaceAll('{color}', color.value)
), props.inputClass)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
toggle,
inputId,
// eslint-disable-next-line vue/no-dupe-keys
name,
// eslint-disable-next-line vue/no-dupe-keys
inputClass,
onChange
}
}
})
</script>

View File

@@ -1,276 +0,0 @@
<template>
<form @submit.prevent="onSubmit">
<slot />
</form>
</template>
<script lang="ts">
import { provide, ref, type PropType, defineComponent, onUnmounted, onMounted } from 'vue'
import { useEventBus } from '@vueuse/core'
import type { ZodSchema } from 'zod'
import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi'
import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup'
import type { ObjectSchemaAsync as ValibotObjectSchema } from 'valibot'
import type { FormError, FormEvent, FormEventType, FormSubmitEvent, FormErrorEvent, Form } from '../../types/form'
import { useId } from '#imports'
class FormException extends Error {
constructor (message: string) {
super(message)
this.message = message
Object.setPrototypeOf(this, FormException.prototype)
}
}
export default defineComponent({
props: {
schema: {
type: Object as
| PropType<ZodSchema>
| PropType<YupObjectSchema<any>>
| PropType<JoiSchema>
| PropType<ValibotObjectSchema<any>>,
default: undefined
},
state: {
type: Object,
required: true
},
validate: {
type: Function as
| PropType<(state: any) => Promise<FormError[]>>
| PropType<(state: any) => FormError[]>,
default: () => []
},
validateOn: {
type: Array as PropType<FormEventType[]>,
default: () => ['blur', 'input', 'change', 'submit']
}
},
emits: ['submit', 'error'],
setup (props, { expose, emit }) {
const formId = useId()
const bus = useEventBus<FormEvent>(`form-${formId}`)
onMounted(() => {
bus.on(async (event) => {
if (event.type !== 'submit' && props.validateOn?.includes(event.type)) {
await validate(event.path, { silent: true })
}
})
})
onUnmounted(() => {
bus.reset()
})
const errors = ref<FormError[]>([])
provide('form-errors', errors)
provide('form-events', bus)
const inputs = ref({})
provide('form-inputs', inputs)
async function getErrors (): Promise<FormError[]> {
let errs = await props.validate(props.state)
if (props.schema) {
if (isZodSchema(props.schema)) {
errs = errs.concat(await getZodErrors(props.state, props.schema))
} else if (isYupSchema(props.schema)) {
errs = errs.concat(await getYupErrors(props.state, props.schema))
} else if (isJoiSchema(props.schema)) {
errs = errs.concat(await getJoiErrors(props.state, props.schema))
} else if (isValibotSchema(props.schema)) {
errs = errs.concat(await getValibotError(props.state, props.schema))
} else {
throw new Error('Form validation failed: Unsupported form schema')
}
}
return errs
}
async function validate (path?: string | string[], opts: { silent?: boolean } = { silent: false }) {
let paths = path
if (path && !Array.isArray(path)) {
paths = [path]
}
if (paths) {
const otherErrors = errors.value.filter(
(error) => !paths.includes(error.path)
)
const pathErrors = (await getErrors()).filter(
(error) => paths.includes(error.path)
)
errors.value = otherErrors.concat(pathErrors)
} else {
errors.value = await getErrors()
}
if (errors.value.length > 0) {
if (opts.silent) return false
throw new FormException(
`Form validation failed: ${JSON.stringify(errors.value, null, 2)}`
)
}
return props.state
}
async function onSubmit (payload: Event) {
const event = payload as SubmitEvent
try {
if (props.validateOn?.includes('submit')) {
await validate()
}
const submitEvent: FormSubmitEvent<any> = {
...event,
data: props.state
}
emit('submit', submitEvent)
} catch (error) {
if (!(error instanceof FormException)) {
throw error
}
const errorEvent: FormErrorEvent = {
...event,
errors: errors.value.map((err) => ({
...err,
id: inputs.value[err.path]
}))
}
emit('error', errorEvent)
}
}
expose({
validate,
errors,
setErrors (errs: FormError[], path?: string) {
errors.value = errs
if (path) {
errors.value = errors.value.filter(
(error) => error.path !== path
).concat(errs)
} else {
errors.value = errs
}
},
async submit () {
await onSubmit(new Event('submit'))
},
getErrors (path?: string) {
if (path) {
return errors.value.filter((err) => err.path === path)
}
return errors.value
},
clear (path?: string) {
if (path) {
errors.value = errors.value.filter((err) => err.path !== path)
} else {
errors.value = []
}
}
} as Form<any>)
return {
onSubmit
}
}
})
function isYupSchema (schema: any): schema is YupObjectSchema<any> {
return schema.validate && schema.__isYupSchema__
}
function isYupError (error: any): error is YupError {
return error.inner !== undefined
}
async function getYupErrors (
state: any,
schema: YupObjectSchema<any>
): Promise<FormError[]> {
try {
await schema.validate(state, { abortEarly: false })
return []
} catch (error) {
if (isYupError(error)) {
return error.inner.map((issue) => ({
path: issue.path ?? '',
message: issue.message
}))
} else {
throw error
}
}
}
function isZodSchema (schema: any): schema is ZodSchema {
return schema.parse !== undefined
}
async function getZodErrors (
state: any,
schema: ZodSchema
): Promise<FormError[]> {
const result = await schema.safeParseAsync(state)
if (result.success === false) {
return result.error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message
}))
}
return []
}
function isJoiSchema (schema: any): schema is JoiSchema {
return schema.validateAsync !== undefined && schema.id !== undefined
}
function isJoiError (error: any): error is JoiError {
return error.isJoi === true
}
async function getJoiErrors (
state: any,
schema: JoiSchema
): Promise<FormError[]> {
try {
await schema.validateAsync(state, { abortEarly: false })
return []
} catch (error) {
if (isJoiError(error)) {
return error.details.map((detail) => ({
path: detail.path.join('.'),
message: detail.message
}))
} else {
throw error
}
}
}
function isValibotSchema (schema: any): schema is ValibotObjectSchema<any> {
return schema._parse !== undefined
}
async function getValibotError (
state: any,
schema: ValibotObjectSchema<any>
): Promise<FormError[]> {
const result = await schema._parse(state)
if (result.issues) {
return result.issues.map((issue) => ({
path: issue.path?.map(p => p.key).join('.') || '',
message: issue.message
}))
}
return []
}
</script>

View File

@@ -1,140 +0,0 @@
<template>
<div :class="ui.wrapper" v-bind="attrs">
<div :class="ui.inner">
<div v-if="label || $slots.label" :class="[ui.label.wrapper, size]">
<label :for="inputId" :class="[ui.label.base, required ? ui.label.required : '']">
<slot v-if="$slots.label" name="label" v-bind="{ error, label, name, hint, description, help }" />
<template v-else>{{ label }}</template>
</label>
<span v-if="hint || $slots.hint" :class="[ui.hint]">
<slot v-if="$slots.hint" name="hint" v-bind="{ error, label, name, hint, description, help }" />
<template v-else>{{ hint }}</template>
</span>
</div>
<p v-if="description || $slots.description" :class="[ui.description, size]">
<slot v-if="$slots.description" name="description" v-bind="{ error, label, name, hint, description, help }" />
<template v-else>
{{ description }}
</template>
</p>
</div>
<div :class="[label ? ui.container : '']">
<slot v-bind="{ error }" />
<p v-if="(typeof error === 'string' && error) || $slots.error" :class="[ui.error, size]">
<slot v-if="$slots.error" name="error" v-bind="{ error, label, name, hint, description, help }" />
<template v-else>
{{ error }}
</template>
</p>
<p v-else-if="help || $slots.help" :class="[ui.help, size]">
<slot v-if="$slots.help" name="help" v-bind="{ error, label, name, hint, description, help }" />
<template v-else>
{{ help }}
</template>
</p>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, provide, inject, ref, toRef } from 'vue'
import type { Ref, PropType } from 'vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { FormError, InjectedFormGroupValue, FormGroupSize, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { formGroup } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof formGroup>(appConfig.ui.strategy, appConfig.ui.formGroup, formGroup)
export default defineComponent({
inheritAttrs: false,
props: {
name: {
type: String,
default: null
},
size: {
type: String as PropType<FormGroupSize>,
default: null,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
label: {
type: String,
default: null
},
description: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
help: {
type: String,
default: null
},
error: {
type: [String, Boolean],
default: null
},
hint: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
},
eagerValidation: {
type: Boolean,
default: false
}
},
setup (props) {
const { ui, attrs } = useUI('formGroup', toRef(props, 'ui'), config, toRef(props, 'class'))
const formErrors = inject<Ref<FormError[]> | null>('form-errors', null)
const error = computed(() => {
return (props.error && typeof props.error === 'string') || typeof props.error === 'boolean'
? props.error
: formErrors?.value?.find((error) => error.path === props.name)?.message
})
const size = computed(() => ui.value.size[props.size ?? config.default.size])
const inputId = ref(useId())
provide<InjectedFormGroupValue>('form-group', {
error,
inputId,
name: computed(() => props.name),
size: computed(() => props.size),
eagerValidation: computed(() => props.eagerValidation)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
inputId,
// eslint-disable-next-line vue/no-dupe-keys
size,
// eslint-disable-next-line vue/no-dupe-keys
error
}
}
})
</script>

View File

@@ -1,334 +0,0 @@
<template>
<div :class="ui.wrapper">
<input
:id="inputId"
ref="input"
:name="name"
:value="modelValue"
:type="type"
:required="required"
:placeholder="placeholder"
:disabled="disabled"
:class="inputClass"
v-bind="attrs"
@input="onInput"
@blur="onBlur"
@change="onChange"
>
<slot />
<span v-if="(isLeading && leadingIconName) || $slots.leading" :class="leadingWrapperIconClass">
<slot name="leading" :disabled="disabled" :loading="loading">
<UIcon :name="leadingIconName" :class="leadingIconClass" />
</slot>
</span>
<span v-if="(isTrailing && trailingIconName) || $slots.trailing" :class="trailingWrapperIconClass">
<slot name="trailing" :disabled="disabled" :loading="loading">
<UIcon :name="trailingIconName" :class="trailingIconClass" />
</slot>
</span>
</div>
</template>
<script lang="ts">
import { ref, computed, toRef, onMounted, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import { defu } from 'defu'
import { useUI } from '../../composables/useUI'
import { useFormGroup } from '../../composables/useFormGroup'
import { mergeConfig, looseToNumber } from '../../utils'
import { useInjectButtonGroup } from '../../composables/useButtonGroup'
import type { InputSize, InputColor, InputVariant, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { input } from '#ui/ui.config'
const config = mergeConfig<typeof input>(appConfig.ui.strategy, appConfig.ui.input, input)
export default defineComponent({
components: {
UIcon
},
inheritAttrs: false,
props: {
modelValue: {
type: [String, Number],
default: ''
},
type: {
type: String,
default: 'text'
},
id: {
type: String,
default: null
},
name: {
type: String,
default: null
},
placeholder: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
autofocus: {
type: Boolean,
default: false
},
autofocusDelay: {
type: Number,
default: 100
},
icon: {
type: String,
default: null
},
loadingIcon: {
type: String,
default: () => config.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
},
padded: {
type: Boolean,
default: true
},
size: {
type: String as PropType<InputSize>,
default: null,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<InputColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
variant: {
type: String as PropType<InputVariant>,
default: () => config.default.variant,
validator (value: string) {
return [
...Object.keys(config.variant),
...Object.values(config.color).flatMap(value => Object.keys(value))
].includes(value)
}
},
inputClass: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
},
modelModifiers: {
type: Object as PropType<{ trim?: boolean, lazy?: boolean, number?: boolean }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'blur', 'change'],
setup (props, { emit, slots }) {
const { ui, attrs } = useUI('input', toRef(props, 'ui'), config, toRef(props, 'class'))
const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props })
const { emitFormBlur, emitFormInput, size: sizeFormGroup, color, inputId, name } = useFormGroup(props, config)
const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value)
const modelModifiers = ref(defu({}, props.modelModifiers, { trim: false, lazy: false, number: false }))
const input = ref<HTMLInputElement | null>(null)
const autoFocus = () => {
if (props.autofocus) {
input.value?.focus()
}
}
// Custom function to handle the v-model properties
const updateInput = (value: string) => {
if (modelModifiers.value.trim) {
value = value.trim()
}
if (modelModifiers.value.number || props.type === 'number') {
value = looseToNumber(value)
}
emit('update:modelValue', value)
emitFormInput()
}
const onInput = (event: Event) => {
if (!modelModifiers.value.lazy) {
updateInput((event.target as HTMLInputElement).value)
}
}
const onChange = (event: Event) => {
if (props.type === 'file') {
const value = (event.target as HTMLInputElement).files
emit('change', value)
} else {
const value = (event.target as HTMLInputElement).value
emit('change', value)
if (modelModifiers.value.lazy) {
updateInput(value)
}
// Update trimmed input so that it has same behavior as native input https://github.com/vuejs/core/blob/5ea8a8a4fab4e19a71e123e4d27d051f5e927172/packages/runtime-dom/src/directives/vModel.ts#L63
if (modelModifiers.value.trim) {
(event.target as HTMLInputElement).value = value.trim()
}
}
}
const onBlur = (event: FocusEvent) => {
emitFormBlur()
emit('blur', event)
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
const inputClass = computed(() => {
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return twMerge(twJoin(
ui.value.base,
ui.value.form,
rounded.value,
ui.value.placeholder,
props.type === 'file' && [ui.value.file.base, ui.value.file.padding[size.value]],
ui.value.size[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
(isLeading.value || slots.leading) && ui.value.leading.padding[size.value],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[size.value]
), props.inputClass)
})
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 leadingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.pointer,
ui.value.icon.leading.padding[size.value]
)
})
const leadingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && ui.value.icon.loading
)
})
const trailingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.pointer,
ui.value.icon.trailing.padding[size.value]
)
})
const trailingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && !isLeading.value && ui.value.icon.loading
)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
name,
inputId,
input,
isLeading,
isTrailing,
// eslint-disable-next-line vue/no-dupe-keys
inputClass,
leadingIconName,
leadingIconClass,
leadingWrapperIconClass,
trailingIconName,
trailingIconClass,
trailingWrapperIconClass,
onInput,
onChange,
onBlur
}
}
})
</script>

View File

@@ -1,467 +0,0 @@
<template>
<HCombobox
v-slot="{ open }"
:by="by"
:name="name"
:model-value="modelValue"
:disabled="disabled"
:nullable="nullable"
as="div"
:class="ui.wrapper"
@update:model-value="onUpdate"
>
<div :class="uiMenu.trigger">
<HComboboxInput
:id="inputId"
:name="name"
:required="required"
:placeholder="placeholder"
:disabled="disabled"
:class="inputClass"
autocomplete="off"
v-bind="attrs"
:display-value="() => query ? query : label"
@change="onQueryChange"
/>
<span v-if="(isLeading && leadingIconName) || $slots.leading" :class="leadingWrapperIconClass">
<slot name="leading" :disabled="disabled" :loading="loading">
<UIcon :name="leadingIconName" :class="leadingIconClass" />
</slot>
</span>
<HComboboxButton v-if="(isTrailing && trailingIconName) || $slots.trailing" ref="trigger" :class="trailingWrapperIconClass">
<slot name="trailing" :disabled="disabled" :loading="loading">
<UIcon :name="trailingIconName" :class="trailingIconClass" />
</slot>
</HComboboxButton>
</div>
<div v-if="open" ref="container" :class="[uiMenu.container, uiMenu.width]">
<Transition appear v-bind="uiMenu.transition">
<div>
<div v-if="popper.arrow" data-popper-arrow :class="Object.values(uiMenu.arrow)" />
<HComboboxOptions static :class="[uiMenu.base, uiMenu.ring, uiMenu.rounded, uiMenu.shadow, uiMenu.background, uiMenu.padding, uiMenu.height]">
<HComboboxOption
v-for="(option, index) in filteredOptions"
v-slot="{ active, selected, disabled: optionDisabled }"
:key="index"
as="template"
:value="valueAttribute ? option[valueAttribute] : option"
:disabled="option.disabled"
>
<li :class="[uiMenu.option.base, uiMenu.option.rounded, uiMenu.option.padding, uiMenu.option.size, uiMenu.option.color, active ? uiMenu.option.active : uiMenu.option.inactive, selected && uiMenu.option.selected, optionDisabled && uiMenu.option.disabled]">
<div :class="uiMenu.option.container">
<slot name="option" :option="option" :active="active" :selected="selected">
<UIcon v-if="option.icon" :name="option.icon" :class="[uiMenu.option.icon.base, active ? uiMenu.option.icon.active : uiMenu.option.icon.inactive, option.iconClass]" aria-hidden="true" />
<UAvatar
v-else-if="option.avatar"
v-bind="{ size: uiMenu.option.avatar.size, ...option.avatar }"
:class="uiMenu.option.avatar.base"
aria-hidden="true"
/>
<span v-else-if="option.chip" :class="uiMenu.option.chip.base" :style="{ background: `#${option.chip}` }" />
<span class="truncate">{{ ['string', 'number'].includes(typeof option) ? option : option[optionAttribute] }}</span>
</slot>
</div>
<span v-if="selected" :class="[uiMenu.option.selectedIcon.wrapper, uiMenu.option.selectedIcon.padding]">
<UIcon :name="selectedIcon" :class="uiMenu.option.selectedIcon.base" aria-hidden="true" />
</span>
</li>
</HComboboxOption>
<p v-if="query && !filteredOptions.length" :class="uiMenu.option.empty">
<slot name="option-empty" :query="query">
No results for "{{ query }}".
</slot>
</p>
<p v-else-if="!filteredOptions.length" :class="uiMenu.empty">
<slot name="empty" :query="query">
No options.
</slot>
</p>
</HComboboxOptions>
</div>
</Transition>
</div>
</HCombobox>
</template>
<script lang="ts">
import { ref, computed, toRef, watch, defineComponent } from 'vue'
import type { PropType } from 'vue'
import {
Combobox as HCombobox,
ComboboxButton as HComboboxButton,
ComboboxOptions as HComboboxOptions,
ComboboxOption as HComboboxOption,
ComboboxInput as HComboboxInput,
provideUseId
} from '@headlessui/vue'
import { computedAsync, useDebounceFn } from '@vueuse/core'
import { defu } from 'defu'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import { useUI } from '../../composables/useUI'
import { usePopper } from '../../composables/usePopper'
import { useFormGroup } from '../../composables/useFormGroup'
import { get, mergeConfig } from '../../utils'
import { useInjectButtonGroup } from '../../composables/useButtonGroup'
import type { InputSize, InputColor, InputVariant, PopperOptions, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { input, inputMenu } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof input>(appConfig.ui.strategy, appConfig.ui.input, input)
const configMenu = mergeConfig<typeof inputMenu>(appConfig.ui.strategy, appConfig.ui.inputMenu, inputMenu)
export default defineComponent({
components: {
HCombobox,
HComboboxButton,
HComboboxOptions,
HComboboxOption,
HComboboxInput,
UIcon,
UAvatar
},
inheritAttrs: false,
props: {
modelValue: {
type: [String, Number, Object, Array],
default: ''
},
query: {
type: String,
default: null
},
by: {
type: String,
default: undefined
},
options: {
type: Array as PropType<{ [key: string]: any, disabled?: boolean }[] | string[]>,
default: () => []
},
id: {
type: String,
default: null
},
name: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
icon: {
type: String,
default: null
},
loadingIcon: {
type: String,
default: () => config.default.loadingIcon
},
leadingIcon: {
type: String,
default: null
},
trailingIcon: {
type: String,
default: () => configMenu.default.trailingIcon
},
trailing: {
type: Boolean,
default: false
},
leading: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
selectedIcon: {
type: String,
default: () => configMenu.default.selectedIcon
},
disabled: {
type: Boolean,
default: false
},
nullable: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: null
},
padded: {
type: Boolean,
default: true
},
size: {
type: String as PropType<InputSize>,
default: null,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<InputColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
variant: {
type: String as PropType<InputVariant>,
default: () => config.default.variant,
validator (value: string) {
return [
...Object.keys(config.variant),
...Object.values(config.color).flatMap(value => Object.keys(value))
].includes(value)
}
},
optionAttribute: {
type: String,
default: 'label'
},
valueAttribute: {
type: String,
default: null
},
search: {
type: Function as PropType<((query: string) => Promise<any[]> | any[])>,
default: undefined
},
searchAttributes: {
type: Array,
default: null
},
debounce: {
type: Number,
default: 200
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
inputClass: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
},
uiMenu: {
type: Object as PropType<Partial<typeof configMenu> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'update:query', 'open', 'close', 'change'],
setup (props, { emit, slots }) {
const { ui, attrs } = useUI('input', toRef(props, 'ui'), config, toRef(props, 'class'))
const { ui: uiMenu } = useUI('inputMenu', toRef(props, 'uiMenu'), configMenu)
const popper = computed<PopperOptions>(() => defu({}, props.popper, uiMenu.value.popper as PopperOptions))
const [trigger, container] = usePopper(popper.value)
const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props })
const { emitFormBlur, emitFormChange, inputId, color, size: sizeFormGroup, name } = useFormGroup(props, config)
const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value)
const internalQuery = ref('')
const query = computed({
get () {
return props.query ?? internalQuery.value
},
set (value) {
internalQuery.value = value
emit('update:query', value)
}
})
const label = computed(() => {
if (!props.modelValue) {
return
}
if (props.valueAttribute) {
const option = props.options.find(option => option[props.valueAttribute] === props.modelValue)
return option ? option[props.optionAttribute] : null
} else {
return ['string', 'number'].includes(typeof props.modelValue) ? props.modelValue : props.modelValue[props.optionAttribute]
}
})
const inputClass = computed(() => {
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return twMerge(twJoin(
ui.value.base,
ui.value.form,
rounded.value,
ui.value.placeholder,
ui.value.size[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
(isLeading.value || slots.leading) && ui.value.leading.padding[size.value],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[size.value]
), props.inputClass)
})
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 leadingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.pointer,
ui.value.icon.leading.padding[size.value]
)
})
const leadingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && ui.value.icon.loading
)
})
const trailingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.padding[size.value]
)
})
const trailingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && !isLeading.value && ui.value.icon.loading
)
})
const debouncedSearch = props.search && typeof props.search === 'function' ? useDebounceFn(props.search, props.debounce) : undefined
const filteredOptions = computedAsync(async () => {
if (debouncedSearch) {
return await debouncedSearch(query.value)
}
if (query.value === '') {
return props.options
}
return (props.options as any[]).filter((option: any) => {
return (props.searchAttributes?.length ? props.searchAttributes : [props.optionAttribute]).some((searchAttribute: any) => {
if (['string', 'number'].includes(typeof option)) {
return String(option).search(new RegExp(query.value, 'i')) !== -1
}
const child = get(option, searchAttribute)
return child !== null && child !== undefined && String(child).search(new RegExp(query.value, 'i')) !== -1
})
})
})
watch(container, (value) => {
if (value) {
emit('open')
} else {
emit('close')
emitFormBlur()
}
})
function onUpdate (value: any) {
query.value = ''
emit('update:modelValue', value)
emit('change', value)
emitFormChange()
}
function onQueryChange (event: any) {
query.value = event.target.value
}
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
// eslint-disable-next-line vue/no-dupe-keys
uiMenu,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
name,
inputId,
// eslint-disable-next-line vue/no-dupe-keys
popper,
trigger,
container,
label,
isLeading,
isTrailing,
// eslint-disable-next-line vue/no-dupe-keys
inputClass,
leadingIconName,
leadingIconClass,
leadingWrapperIconClass,
trailingIconName,
trailingIconClass,
trailingWrapperIconClass,
filteredOptions,
// eslint-disable-next-line vue/no-dupe-keys
query,
onUpdate,
onQueryChange
}
}
})
</script>

View File

@@ -1,150 +0,0 @@
<template>
<div :class="ui.wrapper" :data-n-ids="attrs['data-n-ids']">
<div :class="ui.container">
<input
:id="inputId"
v-model="pick"
:name="name"
:required="required"
:value="value"
:disabled="disabled"
type="radio"
:class="inputClass"
v-bind="attrs"
@change="onChange"
>
</div>
<div v-if="label || $slots.label" :class="ui.inner">
<label :for="inputId" :class="ui.label">
<slot name="label">{{ label }}</slot>
<span v-if="required" :class="ui.required">*</span>
</label>
<p v-if="help" :class="ui.help">
{{ help }}
</p>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, inject, toRef } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { useFormGroup } from '../../composables/useFormGroup'
import { mergeConfig } from '../../utils'
import type { Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { radio } from '#ui/ui.config'
import colors from '#ui-colors'
import { useId } from '#imports'
const config = mergeConfig<typeof radio>(appConfig.ui.strategy, appConfig.ui.radio, radio)
export default defineComponent({
inheritAttrs: false,
props: {
id: {
type: String,
default: null
},
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
},
color: {
type: String as PropType<typeof colors[number]>,
default: () => config.default.color,
validator (value: string) {
return appConfig.ui.colors.includes(value)
}
},
inputClass: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
const { ui, attrs } = useUI('radio', toRef(props, 'ui'), config, toRef(props, 'class'))
const inputId = props.id ?? useId()
const radioGroup = inject('radio-group', null)
const { emitFormChange, color, name } = radioGroup ?? useFormGroup(props, config)
const pick = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
if (!radioGroup) {
emitFormChange()
}
}
})
function onChange (event: Event) {
emit('change', (event.target as HTMLInputElement).value)
}
const inputClass = computed(() => {
return twMerge(twJoin(
ui.value.base,
ui.value.form,
ui.value.background,
ui.value.border,
color.value && ui.value.ring.replaceAll('{color}', color.value),
color.value && ui.value.color.replaceAll('{color}', color.value)
), props.inputClass)
})
return {
inputId,
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
pick,
// eslint-disable-next-line vue/no-dupe-keys
name,
// eslint-disable-next-line vue/no-dupe-keys
inputClass,
onChange
}
}
})
</script>

View File

@@ -1,151 +0,0 @@
<template>
<div :class="ui.wrapper">
<fieldset v-bind="attrs" :class="ui.fieldset">
<legend v-if="legend || $slots.legend" :class="ui.legend">
<slot name="legend">
{{ legend }}
</slot>
</legend>
<URadio
v-for="option in normalizedOptions"
:key="option.value"
:label="option.label"
:model-value="modelValue"
:value="option.value"
:help="option.help"
:disabled="option.disabled || disabled"
:ui="uiRadio"
@change="onUpdate(option.value)"
>
<template #label>
<slot name="label" v-bind="{ option }" />
</template>
</URadio>
</fieldset>
</div>
</template>
<script lang="ts">
import URadio from './Radio.vue'
import { computed, defineComponent, provide, toRef } from 'vue'
import type { PropType } from 'vue'
import { useUI } from '../../composables/useUI'
import { useFormGroup } from '../../composables/useFormGroup'
import { mergeConfig, get } from '../../utils'
import type { Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { radioGroup, radio } from '#ui/ui.config'
import colors from '#ui-colors'
const config = mergeConfig<typeof radioGroup>(appConfig.ui.strategy, appConfig.ui.radioGroup, radioGroup)
const configRadio = mergeConfig<typeof radio>(appConfig.ui.strategy, appConfig.ui.radio, radio)
export default defineComponent({
components: {
URadio
},
inheritAttrs: false,
props: {
modelValue: {
type: [String, Number, Object],
default: ''
},
name: {
type: String,
default: null
},
legend: {
type: String,
default: null
},
options: {
type: Array,
default: () => []
},
optionAttribute: {
type: String,
default: 'label'
},
valueAttribute: {
type: String,
default: 'value'
},
disabled: {
type: Boolean,
default: false
},
color: {
type: String as PropType<typeof colors[number]>,
default: () => config.default.color,
validator (value: string) {
return appConfig.ui.colors.includes(value)
}
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
},
uiRadio: {
type: Object as PropType<Partial<typeof configRadio> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
const { ui, attrs } = useUI('radioGroup', toRef(props, 'ui'), config, toRef(props, 'class'))
const { ui: uiRadio } = useUI('radio', toRef(props, 'uiRadio'), configRadio)
const { emitFormChange, color, name } = useFormGroup(props, config)
provide('radio-group', { color, name })
const onUpdate = (value: any) => {
emit('update:modelValue', value)
emit('change', value)
emitFormChange()
}
const guessOptionValue = (option: any) => {
return get(option, props.valueAttribute, get(option, props.optionAttribute))
}
const guessOptionText = (option: any) => {
return get(option, props.optionAttribute, get(option, props.valueAttribute))
}
const normalizeOption = (option: any) => {
if (['string', 'number', 'boolean'].includes(typeof option)) {
return {
value: option,
label: option
}
}
return {
...option,
value: guessOptionValue(option),
label: guessOptionText(option)
}
}
const normalizedOptions = computed(() => {
return props.options.map(option => normalizeOption(option))
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
// eslint-disable-next-line vue/no-dupe-keys
uiRadio,
attrs,
normalizedOptions,
// eslint-disable-next-line vue/no-dupe-keys
onUpdate
}
}
})
</script>

View File

@@ -1,188 +0,0 @@
<template>
<div :class="wrapperClass">
<input
:id="inputId"
ref="input"
v-model.number="value"
:name="name"
:min="min"
:max="max"
:disabled="disabled"
:step="step"
type="range"
:class="[inputClass, thumbClass, trackClass]"
v-bind="attrs"
@change="onChange"
>
<span :class="progressClass" :style="progressStyle" />
</div>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { useFormGroup } from '../../composables/useFormGroup'
import { mergeConfig } from '../../utils'
import type { RangeSize, RangeColor, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { range } from '#ui/ui.config'
const config = mergeConfig<typeof range>(appConfig.ui.strategy, appConfig.ui.range, range)
export default defineComponent({
inheritAttrs: false,
props: {
modelValue: {
type: Number,
default: 0
},
id: {
type: String,
default: null
},
name: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
step: {
type: Number,
default: 1
},
size: {
type: String as PropType<RangeSize>,
default: null,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<RangeColor>,
default: () => config.default.color,
validator (value: string) {
return appConfig.ui.colors.includes(value)
}
},
inputClass: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
const { ui, attrs } = useUI('range', toRef(props, 'ui'), config)
const { emitFormChange, inputId, color, size, name } = useFormGroup(props, config)
const value = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const onChange = (event: Event) => {
emit('change', (event.target as HTMLInputElement).value)
emitFormChange()
}
const wrapperClass = computed(() => {
return twMerge(twJoin(
ui.value.wrapper,
ui.value.size[size.value]
), props.class)
})
const inputClass = computed(() => {
return twMerge(twJoin(
ui.value.base,
ui.value.background,
ui.value.rounded,
color.value && ui.value.ring.replaceAll('{color}', color.value),
ui.value.size[size.value]
), props.inputClass)
})
const thumbClass = computed(() => {
return twJoin(
ui.value.thumb.base,
// Intermediate class to allow thumb ring or background color (set to `current`) as it's impossible to safelist with arbitrary values
color.value && ui.value.thumb.color.replaceAll('{color}', color.value),
ui.value.thumb.ring,
ui.value.thumb.background,
ui.value.thumb.size[size.value]
)
})
const trackClass = computed(() => {
return twJoin(
ui.value.track.base,
ui.value.track.background,
ui.value.track.rounded,
ui.value.track.size[size.value]
)
})
const progressClass = computed(() => {
return twJoin(
ui.value.progress.base,
ui.value.progress.rounded,
color.value && ui.value.progress.background.replaceAll('{color}', color.value),
ui.value.progress.size[size.value]
)
})
const progressStyle = computed(() => {
const { modelValue, min, max } = props
const clampedValue = Math.max(min, Math.min(modelValue, max))
const relativeValue = (clampedValue - min) / (max - min)
return {
width: `${relativeValue * 100}%`
}
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
name,
inputId,
value,
wrapperClass,
// eslint-disable-next-line vue/no-dupe-keys
inputClass,
thumbClass,
trackClass,
progressClass,
progressStyle,
onChange
}
}
})
</script>

View File

@@ -1,356 +0,0 @@
<template>
<div :class="ui.wrapper">
<select
:id="inputId"
:name="name"
:value="modelValue"
:required="required"
:disabled="disabled"
:class="selectClass"
v-bind="attrs"
@input="onInput"
@change="onChange"
>
<template v-for="(option, index) in normalizedOptionsWithPlaceholder">
<optgroup
v-if="option.children"
:key="`${option[valueAttribute]}-optgroup-${index}`"
:value="option[valueAttribute]"
:label="option[optionAttribute]"
>
<option
v-for="(childOption, index2) in option.children"
:key="`${childOption[valueAttribute]}-${index}-${index2}`"
:value="childOption[valueAttribute]"
:selected="childOption[valueAttribute] === normalizedValue"
:disabled="childOption.disabled"
v-text="childOption[optionAttribute]"
/>
</optgroup>
<option
v-else
:key="`${option[valueAttribute]}-${index}`"
:value="option[valueAttribute]"
:selected="option[valueAttribute] === normalizedValue"
:disabled="option.disabled"
v-text="option[optionAttribute]"
/>
</template>
</select>
<span v-if="(isLeading && leadingIconName) || $slots.leading" :class="leadingWrapperIconClass">
<slot name="leading" :disabled="disabled" :loading="loading">
<UIcon :name="leadingIconName" :class="leadingIconClass" />
</slot>
</span>
<span v-if="(isTrailing && trailingIconName) || $slots.trailing" :class="trailingWrapperIconClass">
<slot name="trailing" :disabled="disabled" :loading="loading">
<UIcon :name="trailingIconName" :class="trailingIconClass" aria-hidden="true" />
</slot>
</span>
</div>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType, ComputedRef } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import { useUI } from '../../composables/useUI'
import { useFormGroup } from '../../composables/useFormGroup'
import { mergeConfig, get } from '../../utils'
import { useInjectButtonGroup } from '../../composables/useButtonGroup'
import type { SelectSize, SelectColor, SelectVariant, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { select } from '#ui/ui.config'
const config = mergeConfig<typeof select>(appConfig.ui.strategy, appConfig.ui.select, select)
export default defineComponent({
components: {
UIcon
},
inheritAttrs: false,
props: {
modelValue: {
type: [String, Number, Object],
default: ''
},
id: {
type: String,
default: null
},
name: {
type: String,
default: null
},
placeholder: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
icon: {
type: String,
default: null
},
loadingIcon: {
type: String,
default: () => config.default.loadingIcon
},
leadingIcon: {
type: String,
default: null
},
trailingIcon: {
type: String,
default: () => config.default.trailingIcon
},
trailing: {
type: Boolean,
default: false
},
leading: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
padded: {
type: Boolean,
default: true
},
options: {
type: Array,
default: () => []
},
size: {
type: String as PropType<SelectSize>,
default: null,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<SelectColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
variant: {
type: String as PropType<SelectVariant>,
default: () => config.default.variant,
validator (value: string) {
return [
...Object.keys(config.variant),
...Object.values(config.color).flatMap(value => Object.keys(value))
].includes(value)
}
},
optionAttribute: {
type: String,
default: 'label'
},
valueAttribute: {
type: String,
default: 'value'
},
selectClass: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'change'],
setup (props, { emit, slots }) {
const { ui, attrs } = useUI('select', toRef(props, 'ui'), config, toRef(props, 'class'))
const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props })
const { emitFormChange, inputId, color, size: sizeFormGroup, name } = useFormGroup(props, config)
const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value)
const onInput = (event: Event) => {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
const onChange = (event: Event) => {
emit('change', (event.target as HTMLInputElement).value)
emitFormChange()
}
const guessOptionValue = (option: any) => {
return get(option, props.valueAttribute, get(option, props.optionAttribute))
}
const guessOptionText = (option: any) => {
return get(option, props.optionAttribute, get(option, props.valueAttribute))
}
const normalizeOption = (option: any) => {
if (['string', 'number', 'boolean'].includes(typeof option)) {
return {
[props.valueAttribute]: option,
[props.optionAttribute]: option
}
}
return {
...option,
[props.valueAttribute]: guessOptionValue(option),
[props.optionAttribute]: guessOptionText(option)
}
}
const normalizedOptions = computed(() => {
return props.options.map(option => normalizeOption(option))
})
const normalizedOptionsWithPlaceholder: ComputedRef<{ disabled?: boolean, children: { disabled?: boolean }[] }[]> = computed(() => {
if (!props.placeholder) {
return normalizedOptions.value
}
return [
{
[props.valueAttribute]: '',
[props.optionAttribute]: 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(() => {
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return twMerge(twJoin(
ui.value.base,
ui.value.form,
rounded.value,
ui.value.size[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
(isLeading.value || slots.leading) && ui.value.leading.padding[size.value],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[size.value]
), props.placeholder && !props.modelValue && ui.value.placeholder, props.selectClass)
})
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 leadingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.pointer,
ui.value.icon.leading.padding[size.value]
)
})
const leadingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && ui.value.icon.loading
)
})
const trailingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.pointer,
ui.value.icon.trailing.padding[size.value]
)
})
const trailingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && !isLeading.value && ui.value.icon.loading
)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
name,
inputId,
normalizedOptionsWithPlaceholder,
normalizedValue,
isLeading,
isTrailing,
// eslint-disable-next-line vue/no-dupe-keys
selectClass,
leadingIconName,
leadingIconClass,
leadingWrapperIconClass,
trailingIconName,
trailingIconClass,
trailingWrapperIconClass,
onInput,
onChange
}
}
})
</script>
<style scoped>
.form-select {
background-image: none;
}
</style>

View File

@@ -1,553 +0,0 @@
<template>
<component
:is="searchable ? 'HCombobox' : 'HListbox'"
v-slot="{ open }"
:by="by"
:name="name"
:model-value="modelValue"
:multiple="multiple"
:disabled="disabled"
as="div"
:class="ui.wrapper"
@update:model-value="onUpdate"
>
<input
v-if="required"
:value="modelValue"
:required="required"
:class="uiMenu.required"
tabindex="-1"
aria-hidden="true"
>
<component
:is="searchable ? 'HComboboxButton' : 'HListboxButton'"
ref="trigger"
as="div"
role="button"
:class="uiMenu.trigger"
>
<slot :open="open" :disabled="disabled" :loading="loading">
<button :id="inputId" :class="selectClass" :disabled="disabled" type="button" v-bind="attrs">
<span v-if="(isLeading && leadingIconName) || $slots.leading" :class="leadingWrapperIconClass">
<slot name="leading" :disabled="disabled" :loading="loading">
<UIcon :name="leadingIconName" :class="leadingIconClass" />
</slot>
</span>
<slot name="label">
<span v-if="label" :class="uiMenu.label">{{ label }}</span>
<span v-else :class="uiMenu.label">{{ placeholder || '&nbsp;' }}</span>
</slot>
<span v-if="(isTrailing && trailingIconName) || $slots.trailing" :class="trailingWrapperIconClass">
<slot name="trailing" :disabled="disabled" :loading="loading">
<UIcon :name="trailingIconName" :class="trailingIconClass" aria-hidden="true" />
</slot>
</span>
</button>
</slot>
</component>
<div v-if="open" ref="container" :class="[uiMenu.container, uiMenu.width]">
<Transition appear v-bind="uiMenu.transition">
<div>
<div v-if="popper.arrow" data-popper-arrow :class="Object.values(uiMenu.arrow)" />
<component :is="searchable ? 'HComboboxOptions' : 'HListboxOptions'" static :class="[uiMenu.base, uiMenu.ring, uiMenu.rounded, uiMenu.shadow, uiMenu.background, uiMenu.padding, uiMenu.height]">
<HComboboxInput
v-if="searchable"
:display-value="() => query"
name="q"
:placeholder="searchablePlaceholder"
autofocus
autocomplete="off"
:class="uiMenu.input"
@change="onQueryChange"
/>
<component
:is="searchable ? 'HComboboxOption' : 'HListboxOption'"
v-for="(option, index) in filteredOptions"
v-slot="{ active, selected, disabled: optionDisabled }"
:key="index"
as="template"
:value="valueAttribute ? option[valueAttribute] : option"
:disabled="option.disabled"
>
<li :class="[uiMenu.option.base, uiMenu.option.rounded, uiMenu.option.padding, uiMenu.option.size, uiMenu.option.color, active ? uiMenu.option.active : uiMenu.option.inactive, selected && uiMenu.option.selected, optionDisabled && uiMenu.option.disabled]">
<div :class="uiMenu.option.container">
<slot name="option" :option="option" :active="active" :selected="selected">
<UIcon v-if="option.icon" :name="option.icon" :class="[uiMenu.option.icon.base, active ? uiMenu.option.icon.active : uiMenu.option.icon.inactive, option.iconClass]" aria-hidden="true" />
<UAvatar
v-else-if="option.avatar"
v-bind="{ size: uiMenu.option.avatar.size, ...option.avatar }"
:class="uiMenu.option.avatar.base"
aria-hidden="true"
/>
<span v-else-if="option.chip" :class="uiMenu.option.chip.base" :style="{ background: `#${option.chip}` }" />
<span class="truncate">{{ ['string', 'number'].includes(typeof option) ? option : option[optionAttribute] }}</span>
</slot>
</div>
<span v-if="selected" :class="[uiMenu.option.selectedIcon.wrapper, uiMenu.option.selectedIcon.padding]">
<UIcon :name="selectedIcon" :class="uiMenu.option.selectedIcon.base" aria-hidden="true" />
</span>
</li>
</component>
<component :is="searchable ? 'HComboboxOption' : 'HListboxOption'" v-if="creatable && createOption" v-slot="{ active, selected }" :value="createOption" as="template">
<li :class="[uiMenu.option.base, uiMenu.option.rounded, uiMenu.option.padding, uiMenu.option.size, uiMenu.option.color, active ? uiMenu.option.active : uiMenu.option.inactive]">
<div :class="uiMenu.option.container">
<slot name="option-create" :option="createOption" :active="active" :selected="selected">
<span :class="uiMenu.option.create">Create "{{ createOption[optionAttribute] }}"</span>
</slot>
</div>
</li>
</component>
<p v-else-if="searchable && query && !filteredOptions?.length" :class="uiMenu.option.empty">
<slot name="option-empty" :query="query">
No results for "{{ query }}".
</slot>
</p>
<p v-else-if="!filteredOptions?.length" :class="uiMenu.empty">
<slot name="empty" :query="query">
No options.
</slot>
</p>
</component>
</div>
</Transition>
</div>
</component>
</template>
<script lang="ts">
import { ref, computed, toRef, watch, defineComponent } from 'vue'
import type { PropType } from 'vue'
import {
Combobox as HCombobox,
ComboboxButton as HComboboxButton,
ComboboxOptions as HComboboxOptions,
ComboboxOption as HComboboxOption,
ComboboxInput as HComboboxInput,
Listbox as HListbox,
ListboxButton as HListboxButton,
ListboxOptions as HListboxOptions,
ListboxOption as HListboxOption,
provideUseId
} from '@headlessui/vue'
import { computedAsync, useDebounceFn } from '@vueuse/core'
import { defu } from 'defu'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import { useUI } from '../../composables/useUI'
import { usePopper } from '../../composables/usePopper'
import { useFormGroup } from '../../composables/useFormGroup'
import { get, mergeConfig } from '../../utils'
import { useInjectButtonGroup } from '../../composables/useButtonGroup'
import type { SelectSize, SelectColor, SelectVariant, PopperOptions, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { select, selectMenu } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof select>(appConfig.ui.strategy, appConfig.ui.select, select)
const configMenu = mergeConfig<typeof selectMenu>(appConfig.ui.strategy, appConfig.ui.selectMenu, selectMenu)
export default defineComponent({
components: {
HCombobox,
HComboboxButton,
HComboboxOptions,
HComboboxOption,
HComboboxInput,
HListbox,
HListboxButton,
HListboxOptions,
HListboxOption,
UIcon,
UAvatar
},
inheritAttrs: false,
props: {
modelValue: {
type: [String, Number, Object, Array, Boolean],
default: ''
},
query: {
type: String,
default: null
},
by: {
type: String,
default: undefined
},
options: {
type: Array as PropType<{ [key: string]: any, disabled?: boolean }[] | string[]>,
default: () => []
},
id: {
type: String,
default: null
},
name: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
icon: {
type: String,
default: null
},
loadingIcon: {
type: String,
default: () => config.default.loadingIcon
},
leadingIcon: {
type: String,
default: null
},
trailingIcon: {
type: String,
default: () => config.default.trailingIcon
},
trailing: {
type: Boolean,
default: false
},
leading: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
selectedIcon: {
type: String,
default: () => configMenu.default.selectedIcon
},
disabled: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: false
},
searchable: {
type: [Boolean, Function] as PropType<boolean | ((query: string) => Promise<any[]> | any[])>,
default: false
},
searchablePlaceholder: {
type: String,
default: 'Search...'
},
clearSearchOnClose: {
type: Boolean,
default: () => configMenu.default.clearSearchOnClose
},
debounce: {
type: Number,
default: 200
},
creatable: {
type: Boolean,
default: false
},
showCreateOptionWhen: {
type: String as PropType<'always' | 'empty'>,
default: () => configMenu.default.showCreateOptionWhen
},
placeholder: {
type: String,
default: null
},
padded: {
type: Boolean,
default: true
},
size: {
type: String as PropType<SelectSize>,
default: null,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<SelectColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
variant: {
type: String as PropType<SelectVariant>,
default: () => config.default.variant,
validator (value: string) {
return [
...Object.keys(config.variant),
...Object.values(config.color).flatMap(value => Object.keys(value))
].includes(value)
}
},
optionAttribute: {
type: String,
default: 'label'
},
valueAttribute: {
type: String,
default: null
},
searchAttributes: {
type: Array,
default: null
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
selectClass: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
},
uiMenu: {
type: Object as PropType<Partial<typeof configMenu> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'update:query', 'open', 'close', 'change'],
setup (props, { emit, slots }) {
const { ui, attrs } = useUI('select', toRef(props, 'ui'), config, toRef(props, 'class'))
const { ui: uiMenu } = useUI('selectMenu', toRef(props, 'uiMenu'), configMenu)
const popper = computed<PopperOptions>(() => defu({}, props.popper, uiMenu.value.popper as PopperOptions))
const [trigger, container] = usePopper(popper.value)
const { size: sizeButtonGroup, rounded } = useInjectButtonGroup({ ui, props })
const { emitFormBlur, emitFormChange, inputId, color, size: sizeFormGroup, name } = useFormGroup(props, config)
const size = computed(() => sizeButtonGroup.value || sizeFormGroup.value)
const internalQuery = ref('')
const query = computed({
get () {
return props.query ?? internalQuery.value
},
set (value) {
internalQuery.value = value
emit('update:query', value)
}
})
const label = computed(() => {
if (props.multiple) {
if (Array.isArray(props.modelValue) && props.modelValue.length) {
return `${props.modelValue.length} selected`
} else {
return null
}
} else if (props.modelValue !== undefined && props.modelValue !== null) {
if (props.valueAttribute) {
const option = props.options.find(option => option[props.valueAttribute] === props.modelValue)
return option ? option[props.optionAttribute] : null
} else {
return ['string', 'number'].includes(typeof props.modelValue) ? props.modelValue : props.modelValue[props.optionAttribute]
}
}
return null
})
const selectClass = computed(() => {
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return twMerge(twJoin(
ui.value.base,
uiMenu.value.select,
rounded.value,
ui.value.size[size.value],
ui.value.gap[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
(isLeading.value || slots.leading) && ui.value.leading.padding[size.value],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[size.value]
), props.placeholder && (props.modelValue === undefined && props.modelValue === null) && ui.value.placeholder, props.selectClass)
})
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 leadingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.leading.wrapper,
ui.value.icon.leading.pointer,
ui.value.icon.leading.padding[size.value]
)
})
const leadingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && ui.value.icon.loading
)
})
const trailingWrapperIconClass = computed(() => {
return twJoin(
ui.value.icon.trailing.wrapper,
ui.value.icon.trailing.pointer,
ui.value.icon.trailing.padding[size.value]
)
})
const trailingIconClass = computed(() => {
return twJoin(
ui.value.icon.base,
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
ui.value.icon.size[size.value],
props.loading && !isLeading.value && ui.value.icon.loading
)
})
const debouncedSearch = typeof props.searchable === 'function' ? useDebounceFn(props.searchable, props.debounce) : undefined
const filteredOptions = computedAsync(async () => {
if (props.searchable && debouncedSearch) {
return await debouncedSearch(query.value)
}
if (query.value === '') {
return props.options
}
return (props.options as any[]).filter((option: any) => {
return (props.searchAttributes?.length ? props.searchAttributes : [props.optionAttribute]).some((searchAttribute: any) => {
if (['string', 'number'].includes(typeof option)) {
return String(option).search(new RegExp(query.value, 'i')) !== -1
}
const child = get(option, searchAttribute)
return child !== null && child !== undefined && String(child).search(new RegExp(query.value, 'i')) !== -1
})
})
})
const createOption = computed(() => {
if (query.value === '') {
return null
}
if (props.showCreateOptionWhen === 'empty' && filteredOptions.value.length) {
return null
}
if (props.showCreateOptionWhen === 'always') {
const existingOption = filteredOptions.value.find(option => ['string', 'number'].includes(typeof option) ? option === query.value : option[props.optionAttribute] === query.value)
if (existingOption) {
return null
}
}
return ['string', 'number'].includes(typeof props.modelValue) ? query.value : { [props.optionAttribute]: query.value }
})
function clearOnClose () {
if (props.clearSearchOnClose) {
query.value = ''
}
}
watch(container, (value) => {
if (value) {
emit('open')
} else {
clearOnClose()
emit('close')
emitFormBlur()
}
})
function onUpdate (value: any) {
emit('update:modelValue', value)
emit('change', value)
emitFormChange()
}
function onQueryChange (event: any) {
query.value = event.target.value
}
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
// eslint-disable-next-line vue/no-dupe-keys
uiMenu,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
name,
inputId,
// eslint-disable-next-line vue/no-dupe-keys
popper,
trigger,
container,
label,
isLeading,
isTrailing,
// eslint-disable-next-line vue/no-dupe-keys
selectClass,
leadingIconName,
leadingIconClass,
leadingWrapperIconClass,
trailingIconName,
trailingIconClass,
trailingWrapperIconClass,
filteredOptions,
createOption,
// eslint-disable-next-line vue/no-dupe-keys
query,
onUpdate,
onQueryChange
}
}
})
</script>

View File

@@ -1,260 +0,0 @@
<template>
<div :class="ui.wrapper">
<textarea
:id="inputId"
ref="textarea"
:value="modelValue"
:name="name"
:rows="rows"
:required="required"
:disabled="disabled"
:placeholder="placeholder"
:class="textareaClass"
v-bind="attrs"
@input="onInput"
@blur="onBlur"
@change="onChange"
/>
<slot />
</div>
</template>
<script lang="ts">
import { ref, computed, toRef, watch, onMounted, nextTick, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { defu } from 'defu'
import { useUI } from '../../composables/useUI'
import { useFormGroup } from '../../composables/useFormGroup'
import { mergeConfig, looseToNumber } from '../../utils'
import type { TextareaSize, TextareaColor, TextareaVariant, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { textarea } from '#ui/ui.config'
const config = mergeConfig<typeof textarea>(appConfig.ui.strategy, appConfig.ui.textarea, textarea)
export default defineComponent({
inheritAttrs: false,
props: {
modelValue: {
type: [String, Number],
default: ''
},
id: {
type: String,
default: null
},
name: {
type: String,
default: null
},
placeholder: {
type: String,
default: null
},
required: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
rows: {
type: Number,
default: 3
},
maxrows: {
type: Number,
default: 0
},
autoresize: {
type: Boolean,
default: false
},
autofocus: {
type: Boolean,
default: false
},
autofocusDelay: {
type: Number,
default: 100
},
resize: {
type: Boolean,
default: false
},
padded: {
type: Boolean,
default: true
},
size: {
type: String as PropType<TextareaSize>,
default: null,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
color: {
type: String as PropType<TextareaColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, ...Object.keys(config.color)].includes(value)
}
},
variant: {
type: String as PropType<TextareaVariant>,
default: () => config.default.variant,
validator (value: string) {
return [
...Object.keys(config.variant),
...Object.values(config.color).flatMap(value => Object.keys(value))
].includes(value)
}
},
textareaClass: {
type: String,
default: null
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
},
modelModifiers: {
type: Object as PropType<{ trim?: boolean, lazy?: boolean, number?: boolean }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'blur', 'change'],
setup (props, { emit }) {
const { ui, attrs } = useUI('textarea', toRef(props, 'ui'), config, toRef(props, 'class'))
const { emitFormBlur, emitFormInput, inputId, color, size, name } = useFormGroup(props, config)
const modelModifiers = ref(defu({}, props.modelModifiers, { trim: false, lazy: false, number: false }))
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 = props.maxrows ? Math.min(newRows, props.maxrows) : newRows
}
}
}
// Custom function to handle the v-model properties
const updateInput = (value: string) => {
if (modelModifiers.value.trim) {
value = value.trim()
}
if (modelModifiers.value.number) {
value = looseToNumber(value)
}
emit('update:modelValue', value)
emitFormInput()
}
const onInput = (event: Event) => {
autoResize()
if (!modelModifiers.value.lazy) {
updateInput((event.target as HTMLInputElement).value)
}
}
const onChange = (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit('change', value)
if (modelModifiers.value.lazy) {
updateInput(value)
}
// Update trimmed input so that it has same behavior as native input
if (modelModifiers.value.trim) {
(event.target as HTMLInputElement).value = value.trim()
}
}
const onBlur = (event: FocusEvent) => {
emit('blur', event)
emitFormBlur()
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
watch(() => props.modelValue, () => {
nextTick(autoResize)
})
onMounted(() => {
setTimeout(() => {
autoFocus()
autoResize()
}, 100)
})
const textareaClass = computed(() => {
const variant = ui.value.color?.[color.value as string]?.[props.variant as string] || ui.value.variant[props.variant]
return twMerge(twJoin(
ui.value.base,
ui.value.form,
ui.value.rounded,
ui.value.placeholder,
ui.value.size[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),
!props.resize && 'resize-none'
), props.textareaClass)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
name,
inputId,
textarea,
// eslint-disable-next-line vue/no-dupe-keys
textareaClass,
onInput,
onChange,
onBlur
}
}
})
</script>

View File

@@ -1,186 +0,0 @@
<template>
<HSwitch
:id="inputId"
v-model="active"
:name="name"
:disabled="disabled || loading"
:class="switchClass"
v-bind="attrs"
>
<span :class="containerClass">
<span v-if="loading" :class="[ui.icon.active, ui.icon.base]" aria-hidden="true">
<UIcon :name="loadingIcon" :class="loadingIconClass" />
</span>
<span
v-if="!loading && onIcon"
:class="[active ? ui.icon.active : ui.icon.inactive, ui.icon.base]"
aria-hidden="true"
>
<UIcon :name="onIcon" :class="onIconClass" />
</span>
<span
v-if="!loading && offIcon"
:class="[active ? ui.icon.inactive : ui.icon.active, ui.icon.base]"
aria-hidden="true"
>
<UIcon :name="offIcon" :class="offIconClass" />
</span>
</span>
</HSwitch>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { Switch as HSwitch, provideUseId } from '@headlessui/vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import { useUI } from '../../composables/useUI'
import { useFormGroup } from '../../composables/useFormGroup'
import { mergeConfig } from '../../utils'
import type { ToggleSize, ToggleColor, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { toggle } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof toggle>(appConfig.ui.strategy, appConfig.ui.toggle, toggle)
export default defineComponent({
components: {
HSwitch,
UIcon
},
inheritAttrs: false,
props: {
id: {
type: String,
default: null
},
name: {
type: String,
default: null
},
modelValue: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
onIcon: {
type: String,
default: () => config.default.onIcon
},
offIcon: {
type: String,
default: () => config.default.offIcon
},
loadingIcon: {
type: String,
default: () => config.default.loadingIcon
},
color: {
type: String as PropType<ToggleColor>,
default: () => config.default.color,
validator (value: string) {
return appConfig.ui.colors.includes(value)
}
},
size: {
type: String as PropType<ToggleSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(config.size).includes(value)
}
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
const { ui, attrs } = useUI('toggle', toRef(props, 'ui'), config)
const { emitFormChange, color, inputId, name } = useFormGroup(props)
const active = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
emit('change', value)
emitFormChange()
}
})
const switchClass = computed(() => {
return twMerge(twJoin(
ui.value.base,
ui.value.size[props.size],
ui.value.rounded,
color.value && ui.value.ring.replaceAll('{color}', color.value),
color.value && (active.value ? ui.value.active : ui.value.inactive).replaceAll('{color}', color.value)
), props.class)
})
const containerClass = computed(() => {
return twJoin(
ui.value.container.base,
ui.value.container.size[props.size],
(active.value ? ui.value.container.active[props.size] : ui.value.container.inactive)
)
})
const onIconClass = computed(() => {
return twJoin(
ui.value.icon.size[props.size],
color.value && ui.value.icon.on.replaceAll('{color}', color.value)
)
})
const offIconClass = computed(() => {
return twJoin(
ui.value.icon.size[props.size],
color.value && ui.value.icon.off.replaceAll('{color}', color.value)
)
})
const loadingIconClass = computed(() => {
return twJoin(
ui.value.icon.size[props.size],
color.value && ui.value.icon.loading.replaceAll('{color}', color.value)
)
})
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
name,
inputId,
active,
switchClass,
containerClass,
onIconClass,
offIconClass,
loadingIconClass
}
}
})
</script>

View File

@@ -1,70 +0,0 @@
<template>
<component
:is="$attrs.onSubmit ? 'form' : as"
:class="cardClass"
v-bind="attrs"
>
<div v-if="$slots.header" :class="[ui.header.base, ui.header.padding, ui.header.background]">
<slot name="header" />
</div>
<div v-if="$slots.default" :class="[ui.body.base, ui.body.padding, ui.body.background]">
<slot />
</div>
<div v-if="$slots.footer" :class="[ui.footer.base, ui.footer.padding, ui.footer.background]">
<slot name="footer" />
</div>
</component>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { card } from '#ui/ui.config'
const config = mergeConfig<typeof card>(appConfig.ui.strategy, appConfig.ui.card, card)
export default defineComponent({
inheritAttrs: false,
props: {
as: {
type: String,
default: 'div'
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('card', toRef(props, 'ui'), config)
const cardClass = computed(() => {
return twMerge(twJoin(
ui.value.base,
ui.value.rounded,
ui.value.divide,
ui.value.ring,
ui.value.shadow,
ui.value.background
), props.class)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
cardClass
}
}
})
</script>

View File

@@ -1,55 +0,0 @@
<template>
<component :is="as" :class="containerClass" v-bind="attrs">
<slot />
</component>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { container } from '#ui/ui.config'
const config = mergeConfig<typeof container>(appConfig.ui.strategy, appConfig.ui.container, container)
export default defineComponent({
inheritAttrs: false,
props: {
as: {
type: String,
default: 'div'
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('container', toRef(props, 'ui'), config)
const containerClass = computed(() => {
return twMerge(twJoin(
ui.value.base,
ui.value.padding,
ui.value.constrained
), props.class)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
containerClass
}
}
})
</script>

View File

@@ -1,117 +0,0 @@
<template>
<div :class="wrapperClass" v-bind="attrs">
<div :class="borderClass" />
<template v-if="label || icon || avatar || $slots.default">
<div :class="containerClass">
<slot>
<span v-if="label" :class="ui.label">
{{ label }}
</span>
<UIcon v-else-if="icon" :name="icon" :class="ui.icon.base" />
<UAvatar v-else-if="avatar" v-bind="{ size: ui.avatar.size, ...avatar }" :class="ui.avatar.base" />
</slot>
</div>
<div :class="borderClass" />
</template>
</div>
</template>
<script lang="ts">
import { toRef, computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Avatar, DividerSize, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { divider } from '#ui/ui.config'
const config = mergeConfig<typeof divider>(appConfig.ui.strategy, appConfig.ui.divider, divider)
export default defineComponent({
components: {
UIcon,
UAvatar
},
inheritAttrs: false,
props: {
label: {
type: String,
default: null
},
icon: {
type: String,
default: null
},
avatar: {
type: Object as PropType<Avatar>,
default: null
},
size: {
type: String as PropType<DividerSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(config.border.size.horizontal).includes(value) || Object.keys(config.border.size.vertical).includes(value)
}
},
orientation: {
type: String as PropType<'horizontal' | 'vertical'>,
default: 'horizontal',
validator: (value: string) => ['horizontal', 'vertical'].includes(value)
},
type: {
type: String as PropType<'solid' | 'dotted' | 'dashed'>,
default: 'solid',
validator: (value: string) => ['solid', 'dotted', 'dashed'].includes(value)
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('divider', toRef(props, 'ui'), config)
const wrapperClass = computed(() => {
return twMerge(twJoin(
ui.value.wrapper.base,
ui.value.wrapper[props.orientation]
), props.class)
})
const containerClass = computed(() => {
return twJoin(
ui.value.container.base,
ui.value.container[props.orientation]
)
})
const borderClass = computed(() => {
return twJoin(
ui.value.border.base,
ui.value.border[props.orientation],
ui.value.border.size[props.orientation][props.size],
ui.value.border.type[props.type]
)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
wrapperClass,
containerClass,
borderClass
}
}
})
</script>

View File

@@ -1,49 +0,0 @@
<template>
<div :class="skeletonClass" v-bind="attrs" />
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { skeleton } from '#ui/ui.config'
const config = mergeConfig<typeof skeleton>(appConfig.ui.strategy, appConfig.ui.skeleton, skeleton)
export default defineComponent({
inheritAttrs: false,
props: {
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('skeleton', toRef(props, 'ui'), config)
const skeletonClass = computed(() => {
return twMerge(twJoin(
ui.value.base,
ui.value.background,
ui.value.rounded
), props.class)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
skeletonClass
}
}
})
</script>

View File

@@ -1,87 +0,0 @@
<template>
<nav aria-label="Breadcrumb" :class="ui.wrapper" v-bind="attrs">
<ol :class="ui.ol">
<li v-for="(link, index) in links" :key="index" :class="ui.li">
<ULink
as="span"
:class="[ui.base, index === links.length - 1 ? ui.active : !!link.to ? ui.inactive : '']"
v-bind="getULinkProps(link)"
:aria-current="index === links.length - 1 ? 'page' : undefined"
>
<slot name="icon" :link="link" :index="index" :is-active="index === links.length - 1">
<UIcon
v-if="link.icon"
:name="link.icon"
:class="twMerge(twJoin(ui.icon.base, index === links.length - 1 ? ui.icon.active : !!link.to ? ui.icon.inactive : ''), link.iconClass)"
/>
</slot>
<slot :link="link" :index="index" :is-active="index === links.length - 1">
<span v-if="link.label" :class="twMerge(ui.label, link.labelClass)">{{ link.label }}</span>
</slot>
</ULink>
<slot v-if="index < links.length - 1" name="divider">
<template v-if="divider">
<UIcon v-if="divider.startsWith('i-')" :name="divider" :class="ui.divider.base" role="presentation" />
<span v-else role="presentation">{{ divider }}</span>
</template>
</slot>
</li>
</ol>
</nav>
</template>
<script lang="ts">
import { defineComponent, toRef } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import ULink from '../elements/Link.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig, getULinkProps } from '../../utils'
import type { BreadcrumbLink, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { breadcrumb } from '#ui/ui.config'
const config = mergeConfig<typeof breadcrumb>(appConfig.ui.strategy, appConfig.ui.breadcrumb, breadcrumb)
export default defineComponent({
components: {
UIcon,
ULink
},
inheritAttrs: false,
props: {
links: {
type: Array as PropType<BreadcrumbLink[]>,
default: () => []
},
divider: {
type: String,
default: () => config.default.divider
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('breadcrumb', toRef(props, 'ui'), config, toRef(props, 'class'))
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
getULinkProps,
twMerge,
twJoin
}
}
})
</script>

View File

@@ -1,389 +0,0 @@
<template>
<HCombobox
:by="by"
:model-value="modelValue"
:multiple="multiple"
:nullable="nullable"
:class="ui.wrapper"
v-bind="attrs"
as="div"
@update:model-value="onSelect"
>
<div v-show="searchable" :class="ui.input.wrapper">
<UIcon v-if="iconName" :name="iconName" :class="iconClass" aria-hidden="true" />
<HComboboxInput
ref="comboboxInput"
:value="query"
:class="[ui.input.base, ui.input.size, ui.input.height, ui.input.padding, icon && ui.input.icon.padding, closeButton && ui.input.closeButton.padding]"
:placeholder="placeholder"
:aria-label="placeholder"
autocomplete="off"
@change="query = $event.target.value"
/>
<UButton v-if="closeButton" aria-label="Close" v-bind="{ ...(ui.default.closeButton || {}), ...closeButton }" :class="ui.input.closeButton.base" @click="onClear" />
</div>
<HComboboxOptions
v-if="groups.length"
static
hold
as="div"
aria-label="Commands"
:class="ui.container"
>
<CommandPaletteGroup
v-for="group of groups"
:key="group.key"
:query="query"
: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" />
</template>
</CommandPaletteGroup>
</HComboboxOptions>
<template v-else-if="emptyState">
<slot name="empty-state">
<div :class="ui.emptyState.wrapper">
<UIcon v-if="emptyState.icon" :name="emptyState.icon" :class="ui.emptyState.icon" aria-hidden="true" />
<p :class="query ? ui.emptyState.queryLabel : ui.emptyState.label">
{{ query ? emptyState.queryLabel : emptyState.label }}
</p>
</div>
</slot>
</template>
</HCombobox>
</template>
<script lang="ts">
import { ref, computed, watch, toRef, onMounted, defineComponent } from 'vue'
import { Combobox as HCombobox, ComboboxInput as HComboboxInput, ComboboxOptions as HComboboxOptions, provideUseId } from '@headlessui/vue'
import type { ComputedRef, PropType, ComponentPublicInstance } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import { twJoin } from 'tailwind-merge'
import { defu } from 'defu'
import UIcon from '../elements/Icon.vue'
import UButton from '../elements/Button.vue'
import CommandPaletteGroup from './CommandPaletteGroup.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Group, Command, Button, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { commandPalette } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof commandPalette>(appConfig.ui.strategy, appConfig.ui.commandPalette, commandPalette)
export default defineComponent({
components: {
HCombobox,
HComboboxInput,
HComboboxOptions,
UIcon,
UButton,
CommandPaletteGroup
},
inheritAttrs: false,
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
},
loading: {
type: Boolean,
default: false
},
groups: {
type: Array as PropType<Group[]>,
default: () => []
},
icon: {
type: String,
default: () => config.default.icon
},
loadingIcon: {
type: String,
default: () => config.default.loadingIcon
},
selectedIcon: {
type: String,
default: () => config.default.selectedIcon
},
closeButton: {
type: Object as PropType<Button>,
default: () => config.default.closeButton as unknown as Button
},
emptyState: {
type: Object as PropType<{ icon: string, label: string, queryLabel: string }>,
default: () => config.default.emptyState
},
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<UseFuseOptions<Command>>,
default: () => ({})
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'close'],
setup (props, { emit, expose }) {
const { ui, attrs } = useUI('commandPalette', toRef(props, 'ui'), config, toRef(props, 'class'))
const query = ref('')
const comboboxInput = ref<ComponentPublicInstance<HTMLInputElement>>()
const comboboxApi = ref(null)
const isLoading = ref(false)
onMounted(() => {
if (props.autoselect) {
activateNextOption()
}
})
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(() => {
const commands: Command[] = []
for (const group of props.groups) {
if (!group.search) {
commands.push(...(group.commands?.map(command => ({ ...command, group: group.key })) || []))
}
}
return commands
})
const searchResults = ref<{ [key: string]: any }>({})
const { results } = useFuse(query, commands, options)
function getGroupWithCommands (group: Group, commands: Command[]) {
if (!group) {
return
}
if (group.filter && typeof group.filter === 'function') {
commands = group.filter(query.value, commands)
}
return {
...group,
commands: commands.slice(0, options.value.resultLimit)
}
}
const groups = computed(() => {
if (!results.value) {
return []
}
const groupedCommands: Record<string, Command[]> = results.value.reduce((acc, command) => {
const { item, ...data } = command
if (!item.group) {
return acc
}
acc[item.group] ||= []
acc[item.group].push({ ...item, ...data })
return acc
}, {})
const groups: Group[] = Object.entries(groupedCommands).map(([key, commands]) => {
const group = props.groups.find(group => group.key === key)
if (!group) {
return null
}
return getGroupWithCommands(group, commands)
}).filter(Boolean)
const searchGroups = props.groups.filter(group => !!group.search && searchResults.value[group.key]?.length).map(group => {
const commands = (searchResults.value[group.key] || [])
return getGroupWithCommands(group, [...commands])
})
return [
...groups,
...searchGroups
]
})
const debouncedSearch = useDebounceFn(async () => {
const searchableGroups = props.groups.filter(group => !!group.search)
if (!searchableGroups.length) {
return
}
isLoading.value = true
await Promise.all(searchableGroups.map(async (group) => {
// @ts-ignore
searchResults.value[group.key] = await group.search(query.value)
}))
isLoading.value = false
activateFirstOption()
}, props.debounce)
watch(query, () => {
debouncedSearch()
activateFirstOption()
})
const iconName = computed(() => {
if ((props.loading || isLoading.value) && props.loadingIcon) {
return props.loadingIcon
}
return props.icon
})
const iconClass = computed(() => {
return twJoin(
ui.value.input.icon.base,
ui.value.input.icon.size,
((props.loading || isLoading.value) && props.loadingIcon) && ui.value.input.icon.loading
)
})
const emptyState = computed(() => ({ ...ui.value.default.emptyState, ...props.emptyState }))
// Methods
function activateFirstOption () {
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)
}
function activateNextOption () {
setTimeout(() => {
// https://github.com/tailwindlabs/headlessui/blob/6fa6074cd5d3a96f78a2d965392aa44101f5eede/packages/%40headlessui-vue/src/components/combobox/combobox.ts#L769
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
})
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
groups,
comboboxInput,
query,
iconName,
iconClass,
// eslint-disable-next-line vue/no-dupe-keys
emptyState,
onSelect,
onClear
}
}
})
</script>

View File

@@ -1,169 +0,0 @@
<template>
<div :class="ui.group.wrapper">
<h2 v-if="label" :class="ui.group.label">
{{ label }}
</h2>
<div :class="ui.group.container" :aria-label="group[groupAttribute]">
<HComboboxOption
v-for="(command, index) of group.commands"
:key="`${group.key}-${index}`"
v-slot="{ active, selected }"
:value="command"
:disabled="command.disabled"
as="template"
>
<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" :active="active" :selected="selected">
<UIcon 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" />
<UAvatar
v-else-if="command.avatar"
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="ui.group.command.chip.base" :style="{ background: `#${command.chip}` }" />
</slot>
<div :class="[ui.group.command.label, command.disabled && ui.group.command.disabled]">
<slot :name="`${group.key}-command`" :group="group" :command="command" :active="active" :selected="selected">
<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 || 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>
<UIcon v-if="selected" :name="selectedIcon" :class="ui.group.command.selectedIcon.base" aria-hidden="true" />
<slot
v-else-if="active && (group.active || $slots[`${group.key}-active`])"
:name="`${group.key}-active`"
:group="group"
:command="command"
:active="active"
:selected="selected"
>
<span v-if="group.active" :class="ui.group.active">{{ group.active }}</span>
</slot>
<slot
v-else
:name="`${group.key}-inactive`"
:group="group"
:command="command"
:active="active"
:selected="selected"
>
<span v-if="command.shortcuts?.length" :class="ui.group.command.shortcuts">
<UKbd v-for="shortcut of command.shortcuts" :key="shortcut">{{ shortcut }}</UKbd>
</span>
<span v-else-if="!command.disabled && group.inactive" :class="ui.group.inactive">{{ group.inactive }}</span>
</slot>
</div>
</HComboboxOption>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { ComboboxOption as HComboboxOption, provideUseId } from '@headlessui/vue'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import UKbd from '../elements/Kbd.vue'
import type { Group } from '../../types'
import { commandPalette } from '#ui/ui.config'
import { useId } from '#imports'
export default defineComponent({
components: {
HComboboxOption,
UIcon,
UAvatar,
UKbd
},
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<typeof commandPalette>,
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
}
provideUseId(() => useId())
return {
label,
highlight
}
}
})
</script>
<style>
mark {
@apply bg-primary-400;
}
</style>

View File

@@ -1,109 +0,0 @@
<template>
<nav :class="ui.wrapper" v-bind="attrs">
<ul v-for="(section, sectionIndex) of sections" :key="`section${sectionIndex}`" :class="ui.container">
<li v-for="(link, index) of section" :key="`section${sectionIndex}-${index}`" :class="ui.inner">
<ULink
v-slot="{ isActive }"
v-bind="getULinkProps(link)"
:class="[ui.base, ui.before, ui.after]"
:active-class="ui.active"
:inactive-class="ui.inactive"
@click="link.click"
@keyup.enter="$event.target.blur()"
>
<slot name="avatar" :link="link" :is-active="isActive">
<UAvatar
v-if="link.avatar"
v-bind="{ size: ui.avatar.size, ...link.avatar }"
:class="[ui.avatar.base]"
/>
</slot>
<slot name="icon" :link="link" :is-active="isActive">
<UIcon
v-if="link.icon"
:name="link.icon"
:class="twMerge(twJoin(ui.icon.base, isActive ? ui.icon.active : ui.icon.inactive), link.iconClass)"
/>
</slot>
<slot :link="link" :is-active="isActive">
<span v-if="link.label" :class="twMerge(ui.label, link.labelClass)">
<span v-if="isActive" class="sr-only">
Current page:
</span>
{{ link.label }}
</span>
</slot>
<slot name="badge" :link="link" :is-active="isActive">
<UBadge
v-if="link.badge"
v-bind="{
size: ui.badge.size,
color: ui.badge.color,
variant: ui.badge.variant,
...((typeof link.badge === 'string' || typeof link.badge === 'number') ? { label: link.badge } : link.badge)
}"
:class="ui.badge.base"
/>
</slot>
</ULink>
</li>
</ul>
</nav>
</template>
<script lang="ts">
import { toRef, defineComponent, computed } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import UBadge from '../elements/Badge.vue'
import ULink from '../elements/Link.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig, getULinkProps } from '../../utils'
import type { HorizontalNavigationLink, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { horizontalNavigation } from '#ui/ui.config'
const config = mergeConfig<typeof horizontalNavigation>(appConfig.ui.strategy, appConfig.ui.horizontalNavigation, horizontalNavigation)
export default defineComponent({
components: {
UIcon,
UAvatar,
UBadge,
ULink
},
inheritAttrs: false,
props: {
links: {
type: Array as PropType<HorizontalNavigationLink[][] | HorizontalNavigationLink[]>,
default: () => []
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('horizontalNavigation', toRef(props, 'ui'), config, toRef(props, 'class'))
const sections = computed(() => (Array.isArray(props.links[0]) ? props.links : [props.links]) as HorizontalNavigationLink[][])
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
sections,
getULinkProps,
twMerge,
twJoin
}
}
})
</script>

View File

@@ -1,302 +0,0 @@
<template>
<div :class="ui.wrapper" v-bind="attrs">
<slot name="first" :on-click="onClickFirst">
<UButton
v-if="firstButton && showFirst"
:size="size"
:disabled="!canGoFirstOrPrev || disabled"
:class="[ui.base, ui.rounded]"
v-bind="{ ...(ui.default.firstButton || {}), ...firstButton }"
:ui="{ rounded: '' }"
aria-label="First"
@click="onClickFirst"
/>
</slot>
<slot name="prev" :on-click="onClickPrev">
<UButton
v-if="prevButton"
:size="size"
:disabled="!canGoFirstOrPrev || disabled"
:class="[ui.base, ui.rounded]"
v-bind="{ ...(ui.default.prevButton || {}), ...prevButton }"
:ui="{ rounded: '' }"
aria-label="Prev"
@click="onClickPrev"
/>
</slot>
<UButton
v-for="(page, index) of displayedPages"
:key="`${page}-${index}`"
:size="size"
:disabled="disabled"
:label="`${page}`"
v-bind="page === currentPage ? { ...(ui.default.activeButton || {}), ...activeButton } : { ...(ui.default.inactiveButton || {}), ...inactiveButton }"
:class="[{ 'pointer-events-none': typeof page === 'string', 'z-[1]': page === currentPage }, ui.base, ui.rounded]"
:ui="{ rounded: '' }"
@click="() => onClickPage(page)"
/>
<slot name="next" :on-click="onClickNext">
<UButton
v-if="nextButton"
:size="size"
:disabled="!canGoLastOrNext || disabled"
:class="[ui.base, ui.rounded]"
v-bind="{ ...(ui.default.nextButton || {}), ...nextButton }"
:ui="{ rounded: '' }"
aria-label="Next"
@click="onClickNext"
/>
</slot>
<slot name="last" :on-click="onClickLast">
<UButton
v-if="lastButton && showLast"
:size="size"
:disabled="!canGoLastOrNext || disabled"
:class="[ui.base, ui.rounded]"
v-bind="{ ...(ui.default.lastButton || {}), ...lastButton }"
:ui="{ rounded: '' }"
aria-label="Last"
@click="onClickLast"
/>
</slot>
</div>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import UButton from '../elements/Button.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Button, ButtonSize, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { pagination, button } from '#ui/ui.config'
const config = mergeConfig<typeof pagination>(appConfig.ui.strategy, appConfig.ui.pagination, pagination)
const buttonConfig = mergeConfig<typeof button>(appConfig.ui.strategy, appConfig.ui.button, button)
export default defineComponent({
components: {
UButton
},
inheritAttrs: false,
props: {
modelValue: {
type: Number,
required: true
},
pageCount: {
type: Number,
default: 10
},
total: {
type: Number,
required: true
},
max: {
type: Number,
default: 7,
validate (value) {
return value >= 5 && value < Number.MAX_VALUE
}
},
disabled: {
type: Boolean,
default: false
},
size: {
type: String as PropType<ButtonSize>,
default: () => config.default.size,
validator (value: string) {
return Object.keys(buttonConfig.size).includes(value)
}
},
activeButton: {
type: Object as PropType<Button>,
default: () => config.default.activeButton as Button
},
inactiveButton: {
type: Object as PropType<Button>,
default: () => config.default.inactiveButton as Button
},
showFirst: {
type: Boolean,
default: false
},
showLast: {
type: Boolean,
default: false
},
firstButton: {
type: Object as PropType<Button>,
default: () => config.default.firstButton as Button
},
lastButton: {
type: Object as PropType<Button>,
default: () => config.default.lastButton as Button
},
prevButton: {
type: Object as PropType<Button>,
default: () => config.default.prevButton as Button
},
nextButton: {
type: Object as PropType<Button>,
default: () => config.default.nextButton as Button
},
divider: {
type: String,
default: '…'
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue'],
setup (props, { emit }) {
const { ui, attrs } = useUI('pagination', toRef(props, 'ui'), config, toRef(props, 'class'))
const currentPage = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const pages = computed(() => Array.from({ length: Math.ceil(props.total / props.pageCount) }, (_, i) => i + 1))
const displayedPages = computed(() => {
const totalPages = pages.value.length
const current = currentPage.value
const maxDisplayedPages = Math.max(props.max, 5)
const r = Math.floor((Math.min(maxDisplayedPages, totalPages) - 5) / 2)
const r1 = current - r
const r2 = current + r
const beforeWrapped = r1 - 1 > 1
const afterWrapped = r2 + 1 < totalPages
const items: Array<number | string> = []
if (totalPages <= maxDisplayedPages) {
for (let i = 1; i <= totalPages; i++) {
items.push(i)
}
return items
}
items.push(1)
if (beforeWrapped) items.push(props.divider)
if (!afterWrapped) {
const addedItems = (current + r + 2) - totalPages
for (let i = current - r - addedItems; i <= current - r - 1; i++) {
items.push(i)
}
}
for (let i = Math.max(2, r1); i <= Math.min(totalPages, r2); i++) {
items.push(i)
}
if (!beforeWrapped) {
const addedItems = 1 - (current - r - 2)
for (let i = current + r + 1; i <= current + r + addedItems; i++) {
items.push(i)
}
}
if (afterWrapped) items.push(props.divider)
if (r2 < totalPages) {
items.push(totalPages)
}
// Replace divider by number on start edge case [1, '…', 3, ...]
if (items.length >= 3 && items[1] === props.divider && items[2] === 3) {
items[1] = 2
}
// Replace divider by number on end edge case [..., 48, '…', 50]
if (items.length >= 3 && items[items.length - 2] === props.divider && items[items.length - 1] === items.length) {
items[items.length - 2] = items.length - 1
}
return items
})
const canGoFirstOrPrev = computed(() => currentPage.value > 1)
const canGoLastOrNext = computed(() => currentPage.value < pages.value.length)
function onClickFirst () {
if (!canGoFirstOrPrev.value) {
return
}
currentPage.value = 1
}
function onClickLast () {
if (!canGoLastOrNext.value) {
return
}
currentPage.value = pages.value.length
}
function onClickPage (page: number | string) {
if (typeof page === 'string') {
return
}
currentPage.value = page
}
function onClickPrev () {
if (!canGoFirstOrPrev.value) {
return
}
currentPage.value--
}
function onClickNext () {
if (!canGoLastOrNext.value) {
return
}
currentPage.value++
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
currentPage,
pages,
displayedPages,
canGoLastOrNext,
canGoFirstOrPrev,
onClickPrev,
onClickNext,
onClickPage,
onClickFirst,
onClickLast
}
}
})
</script>

View File

@@ -1,167 +0,0 @@
<template>
<HTabGroup
:vertical="orientation === 'vertical'"
:selected-index="selectedIndex"
as="div"
:class="ui.wrapper"
v-bind="attrs"
@change="onChange"
>
<HTabList
ref="listRef"
:class="[ui.list.base, ui.list.background, ui.list.rounded, ui.list.shadow, ui.list.padding, ui.list.width, orientation === 'horizontal' && ui.list.height, orientation === 'horizontal' && 'inline-grid items-center']"
:style="[orientation === 'horizontal' && `grid-template-columns: repeat(${items.length}, minmax(0, 1fr))`]"
>
<div ref="markerRef" :class="ui.list.marker.wrapper">
<div :class="[ui.list.marker.base, ui.list.marker.background, ui.list.marker.rounded, ui.list.marker.shadow]" />
</div>
<HTab
v-for="(item, index) of items"
:key="index"
ref="itemRefs"
v-slot="{ selected, disabled }"
:disabled="item.disabled"
as="template"
>
<button :class="[ui.list.tab.base, ui.list.tab.background, ui.list.tab.height, ui.list.tab.padding, ui.list.tab.size, ui.list.tab.font, ui.list.tab.rounded, ui.list.tab.shadow, selected ? ui.list.tab.active : ui.list.tab.inactive]">
<slot :item="item" :index="index" :selected="selected" :disabled="disabled">
<span class="truncate">{{ item.label }}</span>
</slot>
</button>
</HTab>
</HTabList>
<HTabPanels :class="ui.container">
<HTabPanel v-for="(item, index) of items" :key="index" v-slot="{ selected }" :class="ui.base" :unmount="unmount">
<slot :name="item.slot || 'item'" :item="item" :index="index" :selected="selected">
{{ item.content }}
</slot>
</HTabPanel>
</HTabPanels>
</HTabGroup>
</template>
<script lang="ts">
import { toRef, ref, watch, onMounted, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { TabGroup as HTabGroup, TabList as HTabList, Tab as HTab, TabPanels as HTabPanels, TabPanel as HTabPanel, provideUseId } from '@headlessui/vue'
import { useResizeObserver } from '@vueuse/core'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { TabItem, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { tabs } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof tabs>(appConfig.ui.strategy, appConfig.ui.tabs, tabs)
export default defineComponent({
components: {
HTabGroup,
HTabList,
HTab,
HTabPanels,
HTabPanel
},
inheritAttrs: false,
props: {
modelValue: {
type: Number,
default: undefined
},
orientation: {
type: String as PropType<'horizontal' | 'vertical'>,
default: 'horizontal',
validator: (value: string) => ['horizontal', 'vertical'].includes(value)
},
defaultIndex: {
type: Number,
default: 0
},
items: {
type: Array as PropType<TabItem[]>,
default: () => []
},
unmount: {
type: Boolean,
default: false
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
const { ui, attrs } = useUI('tabs', toRef(props, 'ui'), config, toRef(props, 'class'))
const listRef = ref<HTMLElement>()
const itemRefs = ref<HTMLElement[]>([])
const markerRef = ref<HTMLElement>()
const selectedIndex = ref<number | undefined>(props.modelValue || props.defaultIndex)
// Methods
function calcMarkerSize (index: number | undefined) {
// @ts-ignore
const tab = itemRefs.value[index]?.$el
if (!tab) {
return
}
if (!markerRef.value) {
return
}
markerRef.value.style.top = `${tab.offsetTop}px`
markerRef.value.style.left = `${tab.offsetLeft}px`
markerRef.value.style.width = `${tab.offsetWidth}px`
markerRef.value.style.height = `${tab.offsetHeight}px`
}
function onChange (index: number) {
selectedIndex.value = index
emit('change', index)
if (props.modelValue !== undefined) {
emit('update:modelValue', selectedIndex.value)
}
calcMarkerSize(selectedIndex.value)
}
useResizeObserver(listRef, () => {
calcMarkerSize(selectedIndex.value)
})
watch(() => props.modelValue, (value) => {
selectedIndex.value = value
calcMarkerSize(selectedIndex.value)
})
onMounted(() => calcMarkerSize(selectedIndex.value))
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
listRef,
itemRefs,
markerRef,
selectedIndex,
onChange
}
}
})
</script>

View File

@@ -1,112 +0,0 @@
<template>
<nav :class="ui.wrapper" v-bind="attrs">
<ul v-for="(section, sectionIndex) of sections" :key="`section${sectionIndex}`">
<li v-for="(link, index) of section" :key="`section${sectionIndex}-${index}`">
<ULink
v-slot="{ isActive }"
v-bind="getULinkProps(link)"
:class="[ui.base, ui.padding, ui.width, ui.ring, ui.rounded, ui.font, ui.size]"
:active-class="ui.active"
:inactive-class="ui.inactive"
@click="link.click"
@keyup.enter="$event.target.blur()"
>
<slot name="avatar" :link="link" :is-active="isActive">
<UAvatar
v-if="link.avatar"
v-bind="{ size: ui.avatar.size, ...link.avatar }"
:class="[ui.avatar.base]"
/>
</slot>
<slot name="icon" :link="link" :is-active="isActive">
<UIcon
v-if="link.icon"
:name="link.icon"
:class="twMerge(twJoin(ui.icon.base, isActive ? ui.icon.active : ui.icon.inactive), link.iconClass)"
/>
</slot>
<slot :link="link" :is-active="isActive">
<span v-if="link.label" :class="twMerge(ui.label, link.labelClass)">
<span v-if="isActive" class="sr-only">
Current page:
</span>
{{ link.label }}
</span>
</slot>
<slot name="badge" :link="link" :is-active="isActive">
<UBadge
v-if="link.badge"
v-bind="{
size: ui.badge.size,
color: ui.badge.color,
variant: ui.badge.variant,
...((typeof link.badge === 'string' || typeof link.badge === 'number') ? { label: link.badge } : link.badge)
}"
:class="ui.badge.base"
/>
</slot>
</ULink>
</li>
<UDivider v-if="sectionIndex < sections.length - 1" :ui="ui.divider" />
</ul>
</nav>
</template>
<script lang="ts">
import { toRef, defineComponent, computed } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import UBadge from '../elements/Badge.vue'
import ULink from '../elements/Link.vue'
import UDivider from '../layout/Divider.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig, getULinkProps } from '../../utils'
import type { VerticalNavigationLink, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { verticalNavigation } from '#ui/ui.config'
const config = mergeConfig<typeof verticalNavigation>(appConfig.ui.strategy, appConfig.ui.verticalNavigation, verticalNavigation)
export default defineComponent({
components: {
UIcon,
UAvatar,
UBadge,
ULink,
UDivider
},
inheritAttrs: false,
props: {
links: {
type: Array as PropType<VerticalNavigationLink[][] | VerticalNavigationLink[]>,
default: () => []
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('verticalNavigation', toRef(props, 'ui'), config, toRef(props, 'class'))
const sections = computed(() => (Array.isArray(props.links[0]) ? props.links : [props.links]) as VerticalNavigationLink[][])
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
sections,
getULinkProps,
twMerge,
twJoin
}
}
})
</script>

View File

@@ -1,98 +0,0 @@
<template>
<div v-if="isOpen" ref="container" :class="wrapperClass" v-bind="attrs">
<Transition appear v-bind="ui.transition">
<div>
<div v-if="popper.arrow" data-popper-arrow :class="Object.values(ui.arrow)" />
<div :class="[ui.base, ui.ring, ui.rounded, ui.shadow, ui.background]">
<slot />
</div>
</div>
</Transition>
</div>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType, Ref } from 'vue'
import { defu } from 'defu'
import { onClickOutside } from '@vueuse/core'
import type { VirtualElement } from '@popperjs/core'
import { twMerge, twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { usePopper } from '../../composables/usePopper'
import { mergeConfig } from '../../utils'
import type { PopperOptions, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { contextMenu } from '#ui/ui.config'
const config = mergeConfig<typeof contextMenu>(appConfig.ui.strategy, appConfig.ui.contextMenu, contextMenu)
export default defineComponent({
inheritAttrs: false,
props: {
modelValue: {
type: Boolean,
default: false
},
virtualElement: {
type: Object,
required: true
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'close'],
setup (props, { emit }) {
const { ui, attrs } = useUI('contextMenu', toRef(props, 'ui'), config)
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)
const wrapperClass = computed(() => {
return twMerge(twJoin(
ui.value.container,
ui.value.width
), props.class)
})
onClickOutside(container, () => {
isOpen.value = false
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
isOpen,
wrapperClass,
// eslint-disable-next-line vue/no-dupe-keys
popper,
container
}
}
})
</script>

View File

@@ -1,132 +0,0 @@
<template>
<TransitionRoot :appear="appear" :show="isOpen" as="template">
<HDialog :class="ui.wrapper" v-bind="attrs" @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="ui.inner">
<div :class="[ui.container, !fullscreen && ui.padding]">
<TransitionChild as="template" :appear="appear" v-bind="transitionClass">
<HDialogPanel
:class="[
ui.base,
ui.background,
ui.ring,
ui.shadow,
fullscreen ? ui.fullscreen : [ui.width, ui.height, ui.rounded, ui.margin],
]"
>
<slot />
</HDialogPanel>
</TransitionChild>
</div>
</div>
</HDialog>
</TransitionRoot>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { Dialog as HDialog, DialogPanel as HDialogPanel, TransitionRoot, TransitionChild, provideUseId } from '@headlessui/vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { modal } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof modal>(appConfig.ui.strategy, appConfig.ui.modal, modal)
export default defineComponent({
components: {
HDialog,
HDialogPanel,
TransitionRoot,
TransitionChild
},
inheritAttrs: false,
props: {
modelValue: {
type: Boolean,
default: false
},
appear: {
type: Boolean,
default: false
},
overlay: {
type: Boolean,
default: true
},
transition: {
type: Boolean,
default: true
},
preventClose: {
type: Boolean,
default: false
},
fullscreen: {
type: Boolean,
default: false
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'close', 'close-prevented'],
setup (props, { emit }) {
const { ui, attrs } = useUI('modal', toRef(props, 'ui'), config, toRef(props, 'class'))
const isOpen = computed({
get () {
return props.modelValue
},
set (value) {
emit('update:modelValue', value)
}
})
const transitionClass = computed(() => {
if (!props.transition) {
return {}
}
return {
...ui.value.transition
}
})
function close (value: boolean) {
if (props.preventClose) {
emit('close-prevented')
return
}
isOpen.value = value
emit('close')
}
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
isOpen,
transitionClass,
close
}
}
})
</script>

View File

@@ -1,12 +0,0 @@
<template>
<component :is="modalState.component" v-if="modalState" v-bind="modalState.props" v-model="isOpen" />
</template>
<script lang="ts" setup>
import { inject } from 'vue'
import { useModal, modalInjectionKey } from '../../composables/useModal'
const modalState = inject(modalInjectionKey)
const { isOpen } = useModal()
</script>

View File

@@ -1,230 +0,0 @@
<template>
<Transition appear v-bind="ui.transition">
<div
:class="wrapperClass"
role="status"
v-bind="attrs"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
>
<div :class="[ui.container, ui.rounded, ui.ring]">
<div class="flex" :class="[ui.padding, ui.gap, { 'items-start': description || $slots.description, 'items-center': !description && !$slots.description }]">
<UIcon v-if="icon" :name="icon" :class="iconClass" />
<UAvatar v-if="avatar" v-bind="{ size: ui.avatar.size, ...avatar }" :class="ui.avatar.base" />
<div :class="ui.inner">
<p v-if="(title || $slots.title)" :class="ui.title">
<slot name="title" :title="title">
{{ title }}
</slot>
</p>
<p v-if="(description || $slots.description)" :class="twMerge(ui.description, !(title && $slots.title) && 'mt-0 leading-5')">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
<div v-if="(description || $slots.description) && actions.length" :class="ui.actions">
<UButton v-for="(action, index) of actions" :key="index" v-bind="{ ...(ui.default.actionButton || {}), ...action }" @click.stop="onAction(action)" />
</div>
</div>
<div v-if="closeButton || (!description && !$slots.description && actions.length)" :class="twMerge(ui.actions, 'mt-0')">
<template v-if="!description && !$slots.description && actions.length">
<UButton v-for="(action, index) of actions" :key="index" v-bind="{ ...(ui.default.actionButton || {}), ...action }" @click.stop="onAction(action)" />
</template>
<UButton v-if="closeButton" aria-label="Close" v-bind="{ ...(ui.default.closeButton || {}), ...closeButton }" @click.stop="onClose" />
</div>
</div>
<div v-if="timeout" :class="progressClass" :style="progressStyle" />
</div>
</div>
</Transition>
</template>
<script lang="ts">
import { ref, computed, toRef, onMounted, onUnmounted, watchEffect, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import UButton from '../elements/Button.vue'
import { useUI } from '../../composables/useUI'
import { useTimer } from '../../composables/useTimer'
import { mergeConfig } from '../../utils'
import type { Avatar, Button, NotificationColor, NotificationAction, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { notification } from '#ui/ui.config'
const config = mergeConfig<typeof notification>(appConfig.ui.strategy, appConfig.ui.notification, notification)
export default defineComponent({
components: {
UIcon,
UAvatar,
UButton
},
inheritAttrs: false,
props: {
id: {
type: [String, Number],
required: true
},
title: {
type: String,
default: null
},
description: {
type: String,
default: null
},
icon: {
type: String,
default: () => config.default.icon
},
avatar: {
type: Object as PropType<Avatar>,
default: null
},
closeButton: {
type: Object as PropType<Button>,
default: () => config.default.closeButton as Button
},
timeout: {
type: Number,
default: () => config.default.timeout
},
actions: {
type: Array as PropType<NotificationAction[]>,
default: () => []
},
callback: {
type: Function,
default: null
},
color: {
type: String as PropType<NotificationColor>,
default: () => config.default.color,
validator (value: string) {
return ['gray', ...appConfig.ui.colors].includes(value)
}
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['close'],
setup (props, { emit }) {
const { ui, attrs } = useUI('notification', toRef(props, 'ui'), config)
let timer: any = null
const remaining = ref(props.timeout)
const wrapperClass = computed(() => {
return twMerge(twJoin(
ui.value.wrapper,
ui.value.background?.replaceAll('{color}', props.color),
ui.value.rounded,
ui.value.shadow
), props.class)
})
const progressClass = computed(() => {
return twJoin(
ui.value.progress.base,
ui.value.progress.background?.replaceAll('{color}', props.color)
)
})
const progressStyle = computed(() => {
const remainingPercent = remaining.value / props.timeout * 100
return { width: `${remainingPercent || 0}%` }
})
const iconClass = computed(() => {
return twJoin(
ui.value.icon.base,
ui.value.icon.color?.replaceAll('{color}', props.color)
)
})
function onMouseover () {
if (timer) {
timer.pause()
}
}
function onMouseleave () {
if (timer) {
timer.resume()
}
}
function onClose () {
if (timer) {
timer.stop()
}
if (props.callback) {
props.callback()
}
emit('close')
}
function onAction (action: NotificationAction) {
if (timer) {
timer.stop()
}
if (action.click) {
action.click()
}
emit('close')
}
onMounted(() => {
if (!props.timeout) {
return
}
timer = useTimer(() => {
onClose()
}, props.timeout)
watchEffect(() => {
remaining.value = timer.remaining.value
})
})
onUnmounted(() => {
if (timer) {
timer.stop()
}
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
wrapperClass,
progressClass,
progressStyle,
iconClass,
onMouseover,
onMouseleave,
onClose,
onAction,
twMerge
}
}
})
</script>

View File

@@ -1,77 +0,0 @@
<template>
<Teleport to="body">
<div :class="wrapperClass" role="region" v-bind="attrs">
<div v-if="notifications.length" :class="ui.container">
<div v-for="notification of notifications" :key="notification.id">
<UNotification
v-bind="notification"
:class="notification.click && 'cursor-pointer'"
@click="notification.click && notification.click(notification)"
@close="toast.remove(notification.id)"
>
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData" />
</template>
</UNotification>
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UNotification from './Notification.vue'
import { useUI } from '../../composables/useUI'
import { useToast } from '../../composables/useToast'
import { mergeConfig } from '../../utils'
import type { Notification, Strategy } from '../../types'
import { useState } from '#imports'
// @ts-expect-error
import appConfig from '#build/app.config'
import { notifications } from '#ui/ui.config'
const config = mergeConfig<typeof notifications>(appConfig.ui.strategy, appConfig.ui.notifications, notifications)
export default defineComponent({
components: {
UNotification
},
inheritAttrs: false,
props: {
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('notifications', toRef(props, 'ui'), config)
const toast = useToast()
const notifications = useState<Notification[]>('notifications', () => [])
const wrapperClass = computed(() => {
return twMerge(twJoin(
ui.value.wrapper,
ui.value.position,
ui.value.width
), props.class)
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
toast,
notifications,
wrapperClass
}
}
})
</script>

View File

@@ -1,245 +0,0 @@
<template>
<!-- eslint-disable-next-line vue/no-template-shadow -->
<HPopover ref="popover" v-slot="{ open, close }" :class="ui.wrapper" v-bind="attrs" @mouseleave="onMouseLeave">
<HPopoverButton
ref="trigger"
as="div"
:disabled="disabled"
:class="ui.trigger"
role="button"
@mouseenter="onMouseEnter"
@touchstart.passive="onTouchStart"
>
<slot :open="open" :close="close">
<button :disabled="disabled">
Open
</button>
</slot>
</HPopoverButton>
<Transition v-if="overlay" appear v-bind="ui.overlay.transition">
<div v-if="open" :class="[ui.overlay.base, ui.overlay.background]" />
</Transition>
<div v-if="open" ref="container" :class="[ui.container, ui.width]" :style="containerStyle" @mouseenter="onMouseEnter">
<Transition appear v-bind="ui.transition">
<div>
<div v-if="popper.arrow" data-popper-arrow :class="Object.values(ui.arrow)" />
<HPopoverPanel :class="[ui.base, ui.background, ui.ring, ui.rounded, ui.shadow]" static>
<slot name="panel" :open="open" :close="close" />
</HPopoverPanel>
</div>
</Transition>
</div>
</HPopover>
</template>
<script lang="ts">
import { computed, ref, toRef, onMounted, defineComponent, watch } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import { Popover as HPopover, PopoverButton as HPopoverButton, PopoverPanel as HPopoverPanel, provideUseId } from '@headlessui/vue'
import { useUI } from '../../composables/useUI'
import { usePopper } from '../../composables/usePopper'
import { mergeConfig } from '../../utils'
import type { PopperOptions, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { popover } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof popover>(appConfig.ui.strategy, appConfig.ui.popover, popover)
export default defineComponent({
components: {
HPopover,
HPopoverButton,
HPopoverPanel
},
inheritAttrs: false,
props: {
mode: {
type: String as PropType<'click' | 'hover'>,
default: 'click',
validator: (value: string) => ['click', 'hover'].includes(value)
},
open: {
type: Boolean,
default: undefined
},
disabled: {
type: Boolean,
default: false
},
openDelay: {
type: Number,
default: () => config.default.openDelay
},
closeDelay: {
type: Number,
default: () => config.default.closeDelay
},
overlay: {
type: Boolean,
default: false
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:open'],
setup (props, { emit }) {
const { ui, attrs } = useUI('popover', toRef(props, 'ui'), config, toRef(props, 'class'))
const popper = computed<PopperOptions>(() => defu(props.mode === 'hover' ? { offsetDistance: 0 } : {}, props.popper, ui.value.popper as PopperOptions))
const [trigger, container] = usePopper(popper.value)
const popover = ref<any>(null)
// https://github.com/tailwindlabs/headlessui/blob/f66f4926c489fc15289d528294c23a3dc2aee7b1/packages/%40headlessui-vue/src/components/popover/popover.ts#L151
const popoverApi = ref<any>(null)
let openTimeout: NodeJS.Timeout | null = null
let closeTimeout: NodeJS.Timeout | null = null
onMounted(() => {
const popoverProvides = popover.value?.$.provides
if (!popoverProvides) {
return
}
const popoverProvidesSymbols = Object.getOwnPropertySymbols(popoverProvides)
popoverApi.value = popoverProvidesSymbols.length && popoverProvides[popoverProvidesSymbols[0]]
if (props.open) {
popoverApi.value?.togglePopover()
}
})
const containerStyle = computed(() => {
if (props.mode !== 'hover') {
return {}
}
const offsetDistance = (props.popper as PopperOptions)?.offsetDistance || (ui.value.popper as PopperOptions)?.offsetDistance || 8
const placement = popper.value.placement?.split('-')[0]
const padding = `${offsetDistance}px`
if (placement === 'top' || placement === 'bottom') {
return {
paddingTop: padding,
paddingBottom: padding
}
} else if (placement === 'left' || placement === 'right') {
return {
paddingLeft: padding,
paddingRight: padding
}
} else {
return {
paddingTop: padding,
paddingBottom: padding,
paddingLeft: padding,
paddingRight: padding
}
}
})
function onTouchStart () {
if (!popoverApi.value) {
return
}
if (popoverApi.value.popoverState === 0) {
popoverApi.value.closePopover()
} else {
popoverApi.value.togglePopover()
}
}
function onMouseEnter () {
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)
}
watch(() => props.open, (newValue: boolean, oldValue: boolean) => {
if (!popoverApi.value) return
if (oldValue === undefined || newValue === oldValue) return
if (newValue) {
// No `openPopover` method and `popoverApi.value.togglePopover` won't work because of the `watch` below
popoverApi.value.popoverState = 0
} else {
popoverApi.value.closePopover()
}
})
watch(() => popoverApi.value?.popoverState, (newValue: number, oldValue: number) => {
if (oldValue === undefined || newValue === oldValue) return
emit('update:open', newValue === 0)
})
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
popover,
// eslint-disable-next-line vue/no-dupe-keys
popper,
trigger,
container,
containerStyle,
onTouchStart,
onMouseEnter,
onMouseLeave
}
}
})
</script>

View File

@@ -1,124 +0,0 @@
<template>
<TransitionRoot as="template" :appear="appear" :show="isOpen">
<HDialog :class="[ui.wrapper, { 'justify-end': side === 'right' }]" v-bind="attrs" @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="transitionClass">
<HDialogPanel :class="[ui.base, ui.width, ui.background, ui.ring, ui.padding]">
<slot />
</HDialogPanel>
</TransitionChild>
</HDialog>
</TransitionRoot>
</template>
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { WritableComputedRef, PropType } from 'vue'
import { Dialog as HDialog, DialogPanel as HDialogPanel, TransitionRoot, TransitionChild, provideUseId } from '@headlessui/vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
import type { Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { slideover } from '#ui/ui.config'
import { useId } from '#imports'
const config = mergeConfig<typeof slideover>(appConfig.ui.strategy, appConfig.ui.slideover, slideover)
export default defineComponent({
components: {
HDialog,
HDialogPanel,
TransitionRoot,
TransitionChild
},
inheritAttrs: false,
props: {
modelValue: {
type: Boolean as PropType<boolean>,
default: false
},
appear: {
type: Boolean,
default: false
},
side: {
type: String as PropType<'left' | 'right'>,
default: 'right',
validator: (value: string) => ['left', 'right'].includes(value)
},
overlay: {
type: Boolean,
default: true
},
transition: {
type: Boolean,
default: true
},
preventClose: {
type: Boolean,
default: false
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
emits: ['update:modelValue', 'close', 'close-prevented'],
setup (props, { emit }) {
const { ui, attrs } = useUI('slideover', toRef(props, 'ui'), config, toRef(props, 'class'))
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' ? ui.value.translate.left : ui.value.translate.right,
enterTo: ui.value.translate.base,
leaveFrom: ui.value.translate.base,
leaveTo: props.side === 'left' ? ui.value.translate.left : ui.value.translate.right
}
})
function close (value: boolean) {
if (props.preventClose) {
emit('close-prevented')
return
}
isOpen.value = value
emit('close')
}
provideUseId(() => useId())
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
attrs,
isOpen,
transitionClass,
close
}
}
})
</script>

View File

@@ -1,17 +0,0 @@
<template>
<component
:is="slideoverState.component"
v-if="slideoverState"
v-bind="slideoverState.props"
v-model="isOpen"
/>
</template>
<script lang="ts" setup>
import { inject } from 'vue'
import { useSlideover, slidOverInjectionKey } from '../../composables/useSlideover'
const slideoverState = inject(slidOverInjectionKey)
const { isOpen } = useSlideover()
</script>

View File

@@ -1,145 +0,0 @@
<template>
<div ref="trigger" :class="ui.wrapper" v-bind="attrs" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
<slot :open="open">
Hover
</slot>
<div v-if="open && !prevent" ref="container" :class="[ui.container, ui.width]">
<Transition appear v-bind="ui.transition">
<div>
<div v-if="popper.arrow" data-popper-arrow :class="Object.values(ui.arrow)" />
<div :class="[ui.base, ui.background, ui.color, ui.rounded, ui.shadow, ui.ring]">
<slot name="text">
{{ text }}
</slot>
<span v-if="shortcuts?.length" :class="ui.shortcuts">
<span :class="ui.middot">&middot;</span>
<UKbd v-for="shortcut of shortcuts" :key="shortcut" size="xs">
{{ shortcut }}
</UKbd>
</span>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script lang="ts">
import { computed, ref, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { defu } from 'defu'
import UKbd from '../elements/Kbd.vue'
import { useUI } from '../../composables/useUI'
import { usePopper } from '../../composables/usePopper'
import { mergeConfig } from '../../utils'
import type { PopperOptions, Strategy } from '../../types'
// @ts-expect-error
import appConfig from '#build/app.config'
import { tooltip } from '#ui/ui.config'
const config = mergeConfig<typeof tooltip>(appConfig.ui.strategy, appConfig.ui.tooltip, tooltip)
export default defineComponent({
components: {
UKbd
},
inheritAttrs: false,
props: {
text: {
type: String,
default: null
},
prevent: {
type: Boolean,
default: false
},
shortcuts: {
type: Array as PropType<string[]>,
default: () => []
},
openDelay: {
type: Number,
default: () => config.default.openDelay
},
closeDelay: {
type: Number,
default: () => config.default.closeDelay
},
popper: {
type: Object as PropType<PopperOptions>,
default: () => ({})
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''
},
ui: {
type: Object as PropType<Partial<typeof config> & { strategy?: Strategy }>,
default: () => ({})
}
},
setup (props) {
const { ui, attrs } = useUI('tooltip', toRef(props, 'ui'), config, toRef(props, 'class'))
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 onMouseEnter () {
// 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,
attrs,
// eslint-disable-next-line vue/no-dupe-keys
popper,
trigger,
container,
open,
onMouseEnter,
onMouseLeave
}
}
})
</script>

View File

@@ -1,164 +0,0 @@
import { ref, computed } from 'vue'
import type { ComputedRef, WatchSource } from 'vue'
import { logicAnd, logicNot } from '@vueuse/math'
import { useEventListener, useDebounceFn } from '@vueuse/core'
import { useShortcuts } from './useShortcuts'
export interface ShortcutConfig {
handler: Function
usingInput?: string | boolean
whenever?: WatchSource<boolean>[]
}
export interface ShortcutsConfig {
[key: string]: ShortcutConfig | Function
}
export interface ShortcutsOptions {
chainDelay?: number
}
interface Shortcut {
handler: Function
condition: ComputedRef<boolean>
chained: boolean
// KeyboardEvent attributes
key: string
ctrlKey: boolean
metaKey: boolean
shiftKey: boolean
altKey: boolean
// code?: string
// keyCode?: number
}
const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/
const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/
export const defineShortcuts = (config: ShortcutsConfig, options: ShortcutsOptions = {}) => {
const { macOS, usingInput } = useShortcuts()
let shortcuts: Shortcut[] = []
const chainedInputs = ref<string[]>([])
const clearChainedInput = () => {
chainedInputs.value.splice(0, chainedInputs.value.length)
}
const debouncedClearChainedInput = useDebounceFn(clearChainedInput, options.chainDelay ?? 800)
const onKeyDown = (e: KeyboardEvent) => {
// Input autocomplete triggers a keydown event
if (!e.key) { return }
const alphabeticalKey = /^[a-z]{1}$/i.test(e.key)
let chainedKey
chainedInputs.value.push(e.key)
// try matching a chained shortcut
if (chainedInputs.value.length >= 2) {
chainedKey = chainedInputs.value.slice(-2).join('-')
for (const shortcut of shortcuts.filter(s => s.chained)) {
if (shortcut.key !== chainedKey) { continue }
if (shortcut.condition.value) {
e.preventDefault()
shortcut.handler()
}
clearChainedInput()
return
}
}
// try matching a standard shortcut
for (const shortcut of shortcuts.filter(s => !s.chained)) {
if (e.key.toLowerCase() !== shortcut.key) { continue }
if (e.metaKey !== shortcut.metaKey) { continue }
if (e.ctrlKey !== shortcut.ctrlKey) { continue }
// shift modifier is only checked in combination with alphabetical keys
// (shift with non-alphabetical keys would change the key)
if (alphabeticalKey && e.shiftKey !== shortcut.shiftKey) { continue }
// alt modifier changes the combined key anyways
// if (e.altKey !== shortcut.altKey) { continue }
if (shortcut.condition.value) {
e.preventDefault()
shortcut.handler()
}
clearChainedInput()
return
}
debouncedClearChainedInput()
}
// Map config to full detailled shortcuts
shortcuts = Object.entries(config).map(([key, shortcutConfig]) => {
if (!shortcutConfig) {
return null
}
// Parse key and modifiers
let shortcut: Partial<Shortcut>
if (key.includes('-') && key !== '-' && !key.match(chainedShortcutRegex)?.length) {
console.trace(`[Shortcut] Invalid key: "${key}"`)
}
if (key.includes('_') && key !== '_' && !key.match(combinedShortcutRegex)?.length) {
console.trace(`[Shortcut] Invalid key: "${key}"`)
}
const chained = key.includes('-') && key !== '-'
if (chained) {
shortcut = {
key: key.toLowerCase(),
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false
}
} else {
const keySplit = key.toLowerCase().split('_').map(k => k)
shortcut = {
key: keySplit.filter(k => !['meta', 'ctrl', 'shift', 'alt'].includes(k)).join('_'),
metaKey: keySplit.includes('meta'),
ctrlKey: keySplit.includes('ctrl'),
shiftKey: keySplit.includes('shift'),
altKey: keySplit.includes('alt')
}
}
shortcut.chained = chained
// Convert Meta to Ctrl for non-MacOS
if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) {
shortcut.metaKey = false
shortcut.ctrlKey = true
}
// Retrieve handler function
if (typeof shortcutConfig === 'function') {
shortcut.handler = shortcutConfig
} else if (typeof shortcutConfig === 'object') {
shortcut = { ...shortcut, handler: shortcutConfig.handler }
}
if (!shortcut.handler) {
console.trace('[Shortcut] Invalid value')
return null
}
// Create shortcut computed
const conditions: ComputedRef<boolean>[] = []
if (!(shortcutConfig as ShortcutConfig).usingInput) {
conditions.push(logicNot(usingInput))
} else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') {
conditions.push(computed(() => usingInput.value === (shortcutConfig as ShortcutConfig).usingInput))
}
shortcut.condition = logicAnd(...conditions, ...((shortcutConfig as ShortcutConfig).whenever || []))
return shortcut as Shortcut
}).filter(Boolean) as Shortcut[]
useEventListener('keydown', onKeyDown)
}

View File

@@ -1,85 +0,0 @@
import { computed, ref, provide, inject, onMounted, onUnmounted, getCurrentInstance } from 'vue'
import type { Ref, ComponentInternalInstance } from 'vue'
import { buttonGroup } from '#ui/ui.config'
type ButtonGroupProps = {
orientation?: Ref<'horizontal' | 'vertical'>
size?: Ref<string>
ui?: Ref<Partial<typeof buttonGroup>>
rounded?: Ref<{ start: string, end: string }>
}
// make a ButtonGroupContext type for injection. Should include ButtonGroupProps
type ButtonGroupContext = {
children: ComponentInternalInstance[]
register (child: ComponentInternalInstance): void
unregister (child: ComponentInternalInstance): void
orientation: 'horizontal' | 'vertical'
size: string
ui: Partial<typeof buttonGroup>
rounded: { start: string, end: string }
}
export function useProvideButtonGroup (buttonGroupProps: ButtonGroupProps) {
const instance = getCurrentInstance()
const groupKey = `group-${instance.uid}`
const state = ref({
children: [],
register (child) {
this.children.push(child)
},
unregister (child) {
const index = this.children.indexOf(child)
if (index > -1) {
this.children.splice(index, 1)
}
},
...buttonGroupProps
})
provide(groupKey, state as Ref<ButtonGroupContext>)
}
export function useInjectButtonGroup ({ ui, props }: { ui: any, props: any }) {
const instance = getCurrentInstance()
provide('ButtonGroupContextConsumer', true)
const isParentPartOfGroup = inject('ButtonGroupContextConsumer', false)
// early return if a parent is already part of the group
if (isParentPartOfGroup) {
return {
size: computed(() => props.size),
rounded: computed(() => ui.value.rounded)
}
}
let parent = instance.parent
let groupContext: Ref<ButtonGroupContext> | undefined
// Traverse up the parent chain to find the nearest ButtonGroup
while (parent && !groupContext) {
if (parent.type.name === 'ButtonGroup') {
groupContext = inject(`group-${parent.uid}`)
break
}
parent = parent.parent
}
const positionInGroup = computed(() => groupContext?.value.children.indexOf(instance))
onMounted(() => {
groupContext?.value.register(instance)
})
onUnmounted(() => {
groupContext?.value.unregister(instance)
})
return {
size: computed(() => groupContext?.value.size || props.size),
rounded: computed(() => {
if (!groupContext || positionInGroup.value === -1) return ui.value.rounded
if (groupContext.value.children.length === 1) return groupContext.value.ui.rounded
if (positionInGroup.value === 0) return groupContext.value.rounded.start
if (positionInGroup.value === groupContext.value.children.length - 1) return groupContext.value.rounded.end
return 'rounded-none'
})
}
}

View File

@@ -1,49 +0,0 @@
import { ref, type Ref, onMounted, onUnmounted } from 'vue'
export const useCarouselScroll = (el: Ref<HTMLElement>) => {
const x = ref<number>(0)
function onMouseDown (e) {
el.value.style.scrollSnapType = 'none'
el.value.style.scrollBehavior = 'auto'
x.value = e.pageX
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
function onMouseUp () {
el.value.style.removeProperty('scroll-behavior')
el.value.style.removeProperty('scroll-snap-type')
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
function onMouseMove (e) {
e.preventDefault()
const delta = e.pageX - x.value
x.value = e.pageX
el.value.scrollBy(-delta, 0)
}
onMounted(() => {
if (!el.value) {
return
}
el.value.addEventListener('mousedown', onMouseDown)
})
onUnmounted(() => {
if (!el.value) {
return
}
el.value.removeEventListener('mousedown', onMouseDown)
})
}

View File

@@ -1,32 +0,0 @@
import { useClipboard } from '@vueuse/core'
import type { Notification } from '../types/notification'
import { useToast } from './useToast'
export function useCopyToClipboard (options: Partial<Notification> = {}) {
const { copy: copyToClipboard, isSupported } = useClipboard()
const toast = useToast()
function copy (text: string, success: { title?: string, description?: string } = {}, failure: { title?: string, description?: string } = {}) {
if (!isSupported) {
return
}
copyToClipboard(text).then(() => {
if (!success.title && !success.description) {
return
}
toast.add({ ...success, ...options })
}, function (e) {
toast.add({
...failure,
description: failure.description || e.message,
...options
})
})
}
return {
copy
}
}

View File

@@ -1,65 +0,0 @@
import { inject, ref, computed } from 'vue'
import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core'
import type { FormEvent, FormEventType, InjectedFormGroupValue } from '../types/form'
type InputProps = {
id?: string
size?: string | number | symbol
color?: string
name?: string
eagerValidation?: boolean
legend?: string | null
}
export const useFormGroup = (inputProps?: InputProps, config?: any) => {
const formBus = inject<UseEventBusReturn<FormEvent, string> | undefined>('form-events', undefined)
const formGroup = inject<InjectedFormGroupValue | undefined>('form-group', undefined)
const formInputs = inject<any>('form-inputs', undefined)
if (formGroup) {
if (inputProps?.id) {
// Updates for="..." attribute on label if inputProps.id is provided
formGroup.inputId.value = inputProps?.id
}
if (formInputs) {
formInputs.value[formGroup.name.value] = formGroup.inputId.value
}
}
const blurred = ref(false)
function emitFormEvent (type: FormEventType, path: string) {
if (formBus) {
formBus.emit({ type, path })
}
}
function emitFormBlur () {
emitFormEvent('blur', formGroup?.name.value as string)
blurred.value = true
}
function emitFormChange () {
emitFormEvent('change', formGroup?.name.value as string)
}
const emitFormInput = useDebounceFn(() => {
if (blurred.value || formGroup?.eagerValidation.value) {
emitFormEvent('input', formGroup?.name.value as string)
}
}, 300)
return {
inputId: computed(() => inputProps?.id ?? formGroup?.inputId.value),
name: computed(() => inputProps?.name ?? formGroup?.name.value),
size: computed(() => {
const formGroupSize = config.size[formGroup?.size.value as string] ? formGroup?.size.value : null
return inputProps?.size ?? formGroupSize ?? config?.default?.size
}),
color: computed(() => formGroup?.error?.value ? 'red' : inputProps?.color),
emitFormBlur,
emitFormInput,
emitFormChange
}
}

View File

@@ -1,51 +0,0 @@
import { ref, inject } from 'vue'
import type { ShallowRef, Component, InjectionKey } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { ComponentProps } from '../types/component'
import type { Modal, ModalState } from '../types/modal'
export const modalInjectionKey: InjectionKey<ShallowRef<ModalState>> = Symbol('nuxt-ui.modal')
function _useModal () {
const modalState = inject(modalInjectionKey)
const isOpen = ref(false)
function open<T extends Component> (component: T, props?: Modal & ComponentProps<T>) {
modalState.value = {
component,
props: props ?? {}
}
isOpen.value = true
}
function close () {
isOpen.value = false
modalState.value = {
component: 'div',
props: {}
}
}
/**
* Allows updating the modal props
*/
function patch <T extends Component = {}> (props: Partial<Modal & ComponentProps<T>>) {
modalState.value = {
...modalState.value,
props: {
...modalState.value.props,
...props
}
}
}
return {
isOpen,
open,
close,
patch
}
}
export const useModal = createSharedComposable(_useModal)

View File

@@ -1,102 +0,0 @@
import { ref, onMounted, watchEffect } from 'vue'
import type { Ref } from 'vue'
import { popperGenerator, defaultModifiers } from '@popperjs/core/lib/popper-lite'
import type { VirtualElement } from '@popperjs/core/lib/popper-lite'
import type { Instance } from '@popperjs/core'
import flip from '@popperjs/core/lib/modifiers/flip'
import offset from '@popperjs/core/lib/modifiers/offset'
import preventOverflow from '@popperjs/core/lib/modifiers/preventOverflow'
import computeStyles from '@popperjs/core/lib/modifiers/computeStyles'
import eventListeners from '@popperjs/core/lib/modifiers/eventListeners'
import arrowModifier from '@popperjs/core/lib/modifiers/arrow'
import { unrefElement } from '@vueuse/core'
import type { MaybeElement } from '@vueuse/core'
import type { PopperOptions } from '../types/popper'
export const createPopper = popperGenerator({
defaultModifiers: [...defaultModifiers, offset, flip, preventOverflow, computeStyles, eventListeners, arrowModifier]
})
export function usePopper ({
locked = false,
overflowPadding = 8,
offsetDistance = 8,
offsetSkid = 0,
gpuAcceleration = true,
adaptive = true,
scroll = true,
resize = true,
arrow = false,
placement,
strategy
}: PopperOptions, virtualReference?: Ref<Element | VirtualElement>) {
const reference = ref<MaybeElement>(null)
const popper = ref<MaybeElement>(null)
const instance = ref<Instance | null>(null)
onMounted(() => {
watchEffect((onInvalidate) => {
if (!popper.value) { return }
if (!reference.value && !virtualReference?.value) { return }
const popperEl = unrefElement(popper)
const referenceEl = virtualReference?.value || unrefElement(reference)
// if (!(referenceEl instanceof HTMLElement)) { return }
if (!(popperEl instanceof HTMLElement)) { return }
if (!referenceEl) { return }
const config: Record<string, any> = {
modifiers: [
{
name: 'flip',
enabled: !locked
},
{
name: 'preventOverflow',
options: {
padding: overflowPadding
}
},
{
name: 'offset',
options: {
offset: [offsetSkid, offsetDistance]
}
},
{
name: 'computeStyles',
options: {
adaptive,
gpuAcceleration
}
},
{
name: 'eventListeners',
options: {
scroll,
resize
}
},
{
name: 'arrow',
enabled: arrow
}
]
}
if (placement) {
config.placement = placement
}
if (strategy) {
config.strategy = strategy
}
instance.value = createPopper(referenceEl, popperEl, config)
onInvalidate(instance.value.destroy)
})
})
return [reference, popper, instance] as const
}

View File

@@ -1,36 +0,0 @@
import { createSharedComposable, useActiveElement } from '@vueuse/core'
import { ref, computed, onMounted } from 'vue'
import type {} from '@vueuse/shared'
export const _useShortcuts = () => {
const macOS = computed(() => import.meta.client && navigator && navigator.userAgent && navigator.userAgent.match(/Macintosh;/))
const metaSymbol = ref(' ')
const activeElement = useActiveElement()
const usingInput = computed(() => {
const tagName = activeElement.value?.tagName
const contentEditable = activeElement.value?.contentEditable
const usingInput = !!(tagName === 'INPUT' || tagName === 'TEXTAREA' || contentEditable === 'true' || contentEditable === 'plaintext-only')
if (usingInput) {
return ((activeElement.value as any)?.name as string) || true
}
return false
})
onMounted(() => {
metaSymbol.value = macOS.value ? '⌘' : 'Ctrl'
})
return {
macOS,
metaSymbol,
activeElement,
usingInput
}
}
export const useShortcuts = createSharedComposable(_useShortcuts)

View File

@@ -1,55 +0,0 @@
import { ref, inject } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { ShallowRef, Component, InjectionKey } from 'vue'
import type { ComponentProps } from '../types/component'
import type { Slideover, SlideoverState } from '../types/slideover'
export const slidOverInjectionKey: InjectionKey<ShallowRef<SlideoverState>> =
Symbol('nuxt-ui.slideover')
function _useSlideover () {
const slideoverState = inject(slidOverInjectionKey)
const isOpen = ref(false)
function open<T extends Component> (component: T, props?: Slideover & ComponentProps<T>) {
if (!slideoverState) {
throw new Error('useSlideover() is called without provider')
}
slideoverState.value = {
component,
props: props ?? {}
}
isOpen.value = true
}
function close () {
if (!slideoverState) return
isOpen.value = false
}
/**
* Allows updating the slideover props
*/
function patch<T extends Component = {}> (props: Partial<Slideover & ComponentProps<T>>) {
if (!slideoverState) return
slideoverState.value = {
...slideoverState.value,
props: {
...slideoverState.value.props,
...props
}
}
}
return {
open,
close,
patch,
isOpen
}
}
export const useSlideover = createSharedComposable(_useSlideover)

View File

@@ -1,63 +0,0 @@
import { ref, computed } from 'vue-demi'
import { useTimestamp } from '@vueuse/core'
import type { UseTimestampOptions } from '@vueuse/core'
export function useTimer (cb: (...args: unknown[]) => any, interval: number, options?: UseTimestampOptions<true>) {
let timer: number | null = null
const { pause: tPause, resume: tResume, timestamp } = useTimestamp({ ...(options || {}), controls: true })
const startTime = ref<number | null>(null)
const remaining = computed(() => {
if (!startTime.value) {
return 0
}
return interval - (timestamp.value - startTime.value)
})
function set (...args: unknown[]) {
timer = setTimeout(() => {
timer = null
startTime.value = null
cb(...args)
}, remaining.value) as unknown as number
}
function clear () {
if (timer) {
clearTimeout(timer)
timer = null
}
}
function start () {
startTime.value = Date.now()
set()
}
function stop () {
clear()
tPause()
}
function pause () {
clear()
tPause()
}
function resume () {
set()
tResume()
startTime.value = (startTime.value || 0) + (Date.now() - timestamp.value)
}
start()
return {
start,
stop,
pause,
resume,
remaining
}
}

View File

@@ -1,29 +0,0 @@
import type { Notification } from '../types/notification'
import { useState } from '#imports'
export function useToast () {
const notifications = useState<Notification[]>('notifications', () => [])
function add (notification: Partial<Notification>) {
const body = {
id: new Date().getTime().toString(),
...notification
}
const index = notifications.value.findIndex((n: Notification) => n.id === body.id)
if (index === -1) {
notifications.value.push(body as Notification)
}
return body
}
function remove (id: string) {
notifications.value = notifications.value.filter((n: Notification) => n.id !== id)
}
return {
add,
remove
}
}

View File

@@ -1,31 +0,0 @@
import { computed, toValue, useAttrs } from 'vue'
import type { Ref } from 'vue'
import { useAppConfig } from '#imports'
import { mergeConfig, omit, get } from '../utils'
import type { Strategy } from '../types'
export const useUI = <T>(key, $ui?: Ref<Partial<T> & { strategy?: Strategy } | undefined>, $config?: Ref<T> | T, $wrapperClass?: Ref<string>, withAppConfig: boolean = false) => {
const $attrs = useAttrs()
const appConfig = useAppConfig()
const ui = computed(() => {
const _ui = toValue($ui)
const _config = toValue($config)
const _wrapperClass = toValue($wrapperClass)
return mergeConfig<T>(
_ui?.strategy || (appConfig.ui?.strategy as Strategy),
_wrapperClass ? { wrapper: _wrapperClass } : {},
_ui || {},
(process.dev || withAppConfig) ? get(appConfig.ui, key, {}) : {},
_config || {}
)
})
const attrs = computed(() => omit($attrs, ['class']))
return {
ui,
attrs
}
}

View File

@@ -1,57 +0,0 @@
import { computed } from 'vue'
import { hexToRgb } from '../utils'
import { defineNuxtPlugin, useAppConfig, useNuxtApp, useHead } from '#imports'
import colors from '#tailwind-config/theme/colors'
export default defineNuxtPlugin(() => {
const appConfig = useAppConfig()
const nuxtApp = useNuxtApp()
const root = computed(() => {
const primary: Record<string, string> | undefined = colors[appConfig.ui.primary]
const gray: Record<string, string> | undefined = colors[appConfig.ui.gray]
if (!primary) {
console.warn(`[@nuxt/ui] Primary color '${appConfig.ui.primary}' not found in Tailwind config`)
}
if (!gray) {
console.warn(`[@nuxt/ui] Gray color '${appConfig.ui.gray}' not found in Tailwind config`)
}
return `:root {
${Object.entries(primary || colors.green).map(([key, value]) => `--color-primary-${key}: ${hexToRgb(value)};`).join('\n')}
--color-primary-DEFAULT: var(--color-primary-500);
${Object.entries(gray || colors.cool).map(([key, value]) => `--color-gray-${key}: ${hexToRgb(value)};`).join('\n')}
}
.dark {
--color-primary-DEFAULT: var(--color-primary-400);
}
`
})
// Head
const headData: any = {
style: [{
innerHTML: () => root.value,
tagPriority: -2,
id: 'nuxt-ui-colors'
}]
}
// SPA mode
if (import.meta.client && nuxtApp.isHydrating && !nuxtApp.payload.serverRendered) {
const style = document.createElement('style')
style.innerHTML = root.value
style.setAttribute('data-nuxt-ui-colors', '')
document.head.appendChild(style)
headData.script = [{
innerHTML: 'document.head.removeChild(document.querySelector(\'[data-nuxt-ui-colors]\'))'
}]
}
useHead(headData)
})

View File

@@ -1,13 +0,0 @@
import { defineNuxtPlugin } from '#imports'
import { shallowRef } from 'vue'
import { modalInjectionKey } from '../composables/useModal'
import type { ModalState } from '../types/modal'
export default defineNuxtPlugin((nuxtApp) => {
const modalState = shallowRef<ModalState>({
component: 'div',
props: {}
})
nuxtApp.vueApp.provide(modalInjectionKey, modalState)
})

View File

@@ -1,13 +0,0 @@
import { defineNuxtPlugin } from '#imports'
import { shallowRef } from 'vue'
import { slidOverInjectionKey } from '../composables/useSlideover'
import type { SlideoverState } from '../types/slideover'
export default defineNuxtPlugin((nuxtApp) => {
const slideoverState = shallowRef<SlideoverState>({
component: 'div',
props: {}
})
nuxtApp.vueApp.provide(slidOverInjectionKey, slideoverState)
})

View File

@@ -1,9 +0,0 @@
import type { Button } from './button'
export interface AccordionItem extends Button {
slot?: string
disabled?: boolean
content?: string
defaultOpen?: boolean
closeOthers?: boolean
}

View File

@@ -1,12 +0,0 @@
import { alert } from '../ui.config'
import type { NestedKeyOf, ExtractDeepKey, ExtractDeepObject } from '.'
import type { Button } from './button'
import colors from '#ui-colors'
import type { AppConfig } from 'nuxt/schema'
export type AlertColor = keyof typeof alert.color | ExtractDeepKey<AppConfig, ['ui', 'alert', 'color']> | typeof colors[number]
export type AlertVariant = keyof typeof alert.variant | ExtractDeepKey<AppConfig, ['ui', 'alert', 'variant']> | NestedKeyOf<typeof alert.color> | NestedKeyOf<ExtractDeepObject<AppConfig, ['ui', 'alert', 'color']>>
export interface AlertAction extends Button {
click?: Function
}

View File

@@ -1,17 +0,0 @@
import { avatar } from '../ui.config'
import type { ExtractDeepKey } from '.'
import colors from '#ui-colors'
import type { AppConfig } from 'nuxt/schema'
export type AvatarSize = keyof typeof avatar.size | ExtractDeepKey<AppConfig, ['ui', 'avatar', 'size']>
export type AvatarChipColor = 'gray' | typeof colors[number]
export type AvatarChipPosition = keyof typeof avatar.chip.position
export interface Avatar {
src?: string | boolean
alt?: string
text?: string
size?: AvatarSize
chipColor?: AvatarChipColor
chipPosition?: AvatarChipPosition
}

View File

@@ -1,15 +0,0 @@
import { badge } from '../ui.config'
import type { NestedKeyOf, ExtractDeepKey, ExtractDeepObject } from '.'
import colors from '#ui-colors'
import type { AppConfig } from 'nuxt/schema'
export type BadgeSize = keyof typeof badge.size | ExtractDeepKey<AppConfig, ['ui', 'badge', 'size']>
export type BadgeColor = keyof typeof badge.color | ExtractDeepKey<AppConfig, ['ui', 'badge', 'color']> | typeof colors[number]
export type BadgeVariant = keyof typeof badge.variant | ExtractDeepKey<AppConfig, ['ui', 'badge', 'variant']> | NestedKeyOf<typeof badge.color> | NestedKeyOf<ExtractDeepObject<AppConfig, ['ui', 'badge', 'color']>>
export interface Badge {
label?: string
size?: BadgeSize
color?: BadgeColor
variant?: BadgeVariant
}

View File

@@ -1,10 +0,0 @@
import type { Link } from './link'
export interface BreadcrumbLink extends Link {
label: string
labelClass?: string
icon?: string
iconClass?: string
// FIXME: This is a workaround for `link.to` not being resolved although it extends `NuxtLinkProps`
[key: string]: any
}

View File

@@ -1,31 +0,0 @@
import type { Link } from './link'
import { button } from '../ui.config'
import type { NestedKeyOf, ExtractDeepKey, ExtractDeepObject } from '.'
import colors from '#ui-colors'
import type { AppConfig } from 'nuxt/schema'
export type ButtonSize = keyof typeof button.size | ExtractDeepKey<AppConfig, ['ui', 'button', 'size']>
export type ButtonColor = keyof typeof button.color | ExtractDeepKey<AppConfig, ['ui', 'button', 'color']> | typeof colors[number]
export type ButtonVariant = keyof typeof button.variant | ExtractDeepKey<AppConfig, ['ui', 'button', 'variant']> | NestedKeyOf<typeof button.color> | NestedKeyOf<ExtractDeepObject<AppConfig, ['ui', 'button', 'color']>>
export interface Button extends Link {
type?: string
block?: boolean
label?: string
loading?: boolean
disabled?: boolean
padded?: boolean
size?: ButtonSize
color?: ButtonColor
variant?: ButtonVariant
icon?: string
loadingIcon?: string
leadingIcon?: string
trailingIcon?: string
trailing?: boolean
leading?: boolean
to?: string | object
target?: string
square?: boolean
truncate?: boolean
}

View File

@@ -1,15 +0,0 @@
import { chip } from '../ui.config'
import colors from '#ui-colors'
export type ChipSize = keyof typeof chip.size
export type ChipColor = 'gray' | typeof colors[number]
export type ChipPosition = keyof typeof chip.position
export interface Chip {
size?: ChipSize
color?: ChipColor
position?: ChipPosition
text?: string
inset?: boolean
show?: boolean
}

View File

@@ -1,3 +0,0 @@
export interface ClipboardPlugin {
copy: (text: string, success?: { title?: string, description?: string }, failure?: { title?: string, description?: string }) => void
}

View File

@@ -1,28 +0,0 @@
import type { FuseSortFunctionMatch, FuseSortFunctionMatchList } from 'fuse.js'
import type { Avatar } from './avatar'
export interface Command {
id: string | number
prefix?: string
suffix?: string
icon?: string
iconClass?: string
avatar?: Avatar
chip?: string
disabled?: boolean
shortcuts?: string[]
group?: string
score?: number
matches?: (FuseSortFunctionMatch | FuseSortFunctionMatchList)[]
[key: string]: any
}
export interface Group {
key: string
active?: string
inactive?: string
commands?: Command[]
search?: Function
filter?: Function
[key: string]: any
}

View File

@@ -1,14 +0,0 @@
export type ComponentProps<T> =
T extends new () => { $props: infer P } ? NonNullable<P> :
T extends (props: infer P, ...args: any) => any ? P :
{}
export type ComponentSlots<T> =
T extends new () => { $slots: infer S } ? NonNullable<S> :
T extends (props: any, ctx: { slots: infer S; attrs: any; emit: any }, ...args: any) => any ? NonNullable<S> :
{}
export type ComponentEmit<T> =
T extends new () => { $emit: infer E } ? NonNullable<E> :
T extends (props: any, ctx: { slots: any; attrs: any; emit: infer E }, ...args: any) => any ? NonNullable<E> :
{}

View File

@@ -1,3 +0,0 @@
import { divider } from '#ui/ui.config'
export type DividerSize = keyof typeof divider.border.size.horizontal | keyof typeof divider.border.size.vertical

View File

@@ -1,15 +0,0 @@
import type { NuxtLinkProps } from '#app'
import type { Avatar } from './avatar'
export interface DropdownItem extends NuxtLinkProps {
label: string
labelClass?: string
slot?: string
icon?: string
iconClass?: string
avatar?: Avatar
shortcuts?: string[]
disabled?: boolean
class?: string
click?: Function
}

View File

@@ -1,5 +0,0 @@
import { formGroup } from '../ui.config'
import type { ExtractDeepKey } from '.'
import type { AppConfig } from 'nuxt/schema'
export type FormGroupSize = keyof typeof formGroup.size | ExtractDeepKey<AppConfig, ['ui', 'formGroup', 'size']>

View File

@@ -1,13 +0,0 @@
import type { Link } from './link'
import type { Avatar } from './avatar'
import type { Badge } from './badge'
export interface HorizontalNavigationLink extends Link {
label: string
labelClass?: string
icon?: string
iconClass?: string
avatar?: Avatar
click?: Function
badge?: string | number | Badge
}

View File

@@ -1,8 +0,0 @@
import { input } from '../ui.config'
import type { NestedKeyOf, ExtractDeepKey, ExtractDeepObject } from '.'
import colors from '#ui-colors'
import type { AppConfig } from 'nuxt/schema'
export type InputSize = keyof typeof input.size | ExtractDeepKey<AppConfig, ['ui', 'input', 'size']>
export type InputColor = keyof typeof input.color | ExtractDeepKey<AppConfig, ['ui', 'input', 'color']> | typeof colors[number]
export type InputVariant = keyof typeof input.variant | ExtractDeepKey<AppConfig, ['ui', 'input', 'variant']> | NestedKeyOf<typeof input.color> | NestedKeyOf<ExtractDeepObject<AppConfig, ['ui', 'input', 'color']>>

View File

@@ -1,5 +0,0 @@
import { kbd } from '../ui.config'
import type { ExtractDeepKey } from '.'
import type { AppConfig } from 'nuxt/schema'
export type KbdSize = keyof typeof kbd.size | ExtractDeepKey<AppConfig, ['ui', 'kbd', 'size']>

View File

@@ -1,12 +0,0 @@
import type { NuxtLinkProps } from '#app'
export interface Link extends NuxtLinkProps {
as?: string
type?: string
disabled?: boolean
active?: boolean
exact?: boolean
exactQuery?: boolean
exactMatch?: boolean
inactiveClass?: string
}

View File

@@ -1,5 +0,0 @@
import { meter } from '../ui.config'
import colors from '#ui-colors'
export type MeterSize = keyof typeof meter.meter.size
export type MeterColor = keyof typeof meter.color | typeof colors[number]

View File

@@ -1,18 +0,0 @@
import type { Component } from 'vue'
export interface Modal {
appear?: boolean
overlay?: boolean
transition?: boolean
preventClose?: boolean
fullscreen?: boolean
class?: string | Object | string[]
ui?: any
onClose?: () => void
onClosePrevented?: () => void
}
export interface ModalState {
component: Component | string
props: Modal
}

View File

@@ -1,24 +0,0 @@
import type { Avatar } from './avatar'
import type { Button } from './button'
import colors from '#ui-colors'
export type NotificationColor = 'gray' | typeof colors[number]
export interface NotificationAction extends Button {
click?: Function
}
export interface Notification {
id: string
title: string
description?: string
icon?: string
avatar?: Avatar
closeButton?: Button
timeout: number
actions?: NotificationAction[]
click?: Function
callback?: Function
color?: NotificationColor
ui?: any
}

View File

@@ -1,17 +0,0 @@
import type { Placement, PositioningStrategy } from '@popperjs/core'
export interface PopperOptions {
// Workaround for weak types: https://mariusschulz.com/blog/weak-type-detection-in-typescript#workarounds-for-weak-types
[key: string]: unknown
locked?: boolean
overflowPadding?: number
offsetDistance?: number
offsetSkid?: number
placement?: Placement
strategy?: PositioningStrategy
gpuAcceleration?: boolean
adaptive?: boolean
scroll?: boolean
resize?: boolean
arrow?: boolean
}

View File

@@ -1,6 +0,0 @@
import { progress } from '../ui.config'
import colors from '#ui-colors'
export type ProgressSize = keyof typeof progress.progress.size
export type ProgressAnimation = keyof typeof progress.animation
export type ProgressColor = typeof colors[number]

View File

@@ -1,7 +0,0 @@
import { range } from '../ui.config'
import type { ExtractDeepKey } from '.'
import type { AppConfig } from 'nuxt/schema'
import colors from '#ui-colors'
export type RangeSize = keyof typeof range.size | ExtractDeepKey<AppConfig, ['ui', 'range', 'size']>
export type RangeColor = typeof colors[number]

View File

@@ -1,8 +0,0 @@
import { select } from '../ui.config'
import type { NestedKeyOf, ExtractDeepKey, ExtractDeepObject } from '.'
import colors from '#ui-colors'
import type { AppConfig } from 'nuxt/schema'
export type SelectSize = keyof typeof select.size | ExtractDeepKey<AppConfig, ['ui', 'select', 'size']>
export type SelectColor = keyof typeof select.color | ExtractDeepKey<AppConfig, ['ui', 'select', 'color']> | typeof colors[number]
export type SelectVariant = keyof typeof select.variant | ExtractDeepKey<AppConfig, ['ui', 'select', 'variant']> | NestedKeyOf<typeof select.color> | NestedKeyOf<ExtractDeepObject<AppConfig, ['ui', 'select', 'color']>>

View File

@@ -1,17 +0,0 @@
import type { Component } from 'vue'
interface Slideover {
ui?: any;
side?: 'right' | 'left';
transition?: boolean;
appear?: boolean;
overlay?: boolean;
preventClose?: boolean;
modelValue?: boolean;
}
interface SlideoverState {
component: Component | string;
props: Slideover;
}

View File

@@ -1,7 +0,0 @@
export interface TabItem {
label: string
slot?: string
disabled?: boolean
content?: string
[key: string]: any
}

View File

@@ -1,8 +0,0 @@
import { textarea } from '../ui.config'
import type { NestedKeyOf, ExtractDeepKey, ExtractDeepObject } from '.'
import colors from '#ui-colors'
import type { AppConfig } from 'nuxt/schema'
export type TextareaSize = keyof typeof textarea.size | ExtractDeepKey<AppConfig, ['ui', 'textarea', 'size']>
export type TextareaColor = keyof typeof textarea.color | ExtractDeepKey<AppConfig, ['ui', 'textarea', 'color']> | typeof colors[number]
export type TextareaVariant = keyof typeof textarea.variant | ExtractDeepKey<AppConfig, ['ui', 'textarea', 'variant']> | NestedKeyOf<typeof textarea.color> | NestedKeyOf<ExtractDeepObject<AppConfig, ['ui', 'textarea', 'color']>>

View File

@@ -1,7 +0,0 @@
import { toggle } from '../ui.config'
import type { ExtractDeepKey } from '.'
import type { AppConfig } from 'nuxt/schema'
import colors from '#ui-colors'
export type ToggleSize = keyof typeof toggle.size | ExtractDeepKey<AppConfig, ['ui', 'toggle', 'size']>
export type ToggleColor = typeof colors[number]

View File

@@ -1,7 +0,0 @@
export interface Tooltip {
text?: string
prevent?: boolean
shortcuts?: string[]
openDelay?: number
closeDelay?: number
}

View File

@@ -1,28 +0,0 @@
export type Strategy = 'merge' | 'override'
export type NestedKeyOf<ObjectType extends Record<string, any>> = {
[Key in keyof ObjectType]: ObjectType[Key] extends Record<string, any>
? NestedKeyOf<ObjectType[Key]>
: Key
}[keyof ObjectType]
export type DeepPartial<T> = Partial<{
[P in keyof T]: DeepPartial<T[P]> | { [key: string]: string | object }
}>
type DeepKey<T, Keys extends string[]> =
Keys extends [infer First, ...infer Rest]
? First extends keyof T
? Rest extends string[]
? DeepKey<T[First], Rest>
: never
: never
: T
export type ExtractDeepKey<T, Path extends string[]> = DeepKey<T, Path> extends infer Result
? Result extends Record<string, any> ? keyof Result : never
: never
export type ExtractDeepObject<T, Path extends string[]> = DeepKey<T, Path> extends infer Result
? Result extends Record<string, any> ? Result : never
: never

View File

@@ -1,13 +0,0 @@
import type { Link } from './link'
import type { Avatar } from './avatar'
import type { Badge } from './badge'
export interface VerticalNavigationLink extends Link {
label: string
labelClass?: string
icon?: string
iconClass?: string
avatar?: Avatar
click?: Function
badge?: string | number | Badge
}

View File

@@ -1,66 +0,0 @@
export default {
wrapper: 'relative overflow-x-auto',
base: 'min-w-full table-fixed',
divide: 'divide-y divide-gray-300 dark:divide-gray-700',
thead: 'relative',
tbody: 'divide-y divide-gray-200 dark:divide-gray-800',
tr: {
base: '',
selected: 'bg-gray-50 dark:bg-gray-800/50',
active: 'hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer'
},
th: {
base: 'text-left rtl:text-right',
padding: 'px-4 py-3.5',
color: 'text-gray-900 dark:text-white',
font: 'font-semibold',
size: 'text-sm'
},
td: {
base: 'whitespace-nowrap',
padding: 'px-4 py-4',
color: 'text-gray-500 dark:text-gray-400',
font: '',
size: 'text-sm'
},
checkbox: {
padding: 'ps-4'
},
loadingState: {
wrapper: 'flex flex-col items-center justify-center flex-1 px-6 py-14 sm:px-14',
label: 'text-sm text-center text-gray-900 dark:text-white',
icon: 'w-6 h-6 mx-auto text-gray-400 dark:text-gray-500 mb-4 animate-spin'
},
emptyState: {
wrapper: 'flex flex-col items-center justify-center flex-1 px-6 py-14 sm:px-14',
label: 'text-sm text-center text-gray-900 dark:text-white',
icon: 'w-6 h-6 mx-auto text-gray-400 dark:text-gray-500 mb-4'
},
progress: {
wrapper: 'absolute inset-x-0 -bottom-[0.5px] p-0'
},
default: {
sortAscIcon: 'i-heroicons-bars-arrow-up-20-solid',
sortDescIcon: 'i-heroicons-bars-arrow-down-20-solid',
sortButton: {
icon: 'i-heroicons-arrows-up-down-20-solid',
trailing: true,
square: true,
color: 'gray' as const,
variant: 'ghost' as const,
class: '-m-1.5'
},
progress: {
color: 'primary' as const,
animation: 'carousel' as const
},
loadingState: {
icon: 'i-heroicons-arrow-path-20-solid',
label: 'Loading...'
},
emptyState: {
icon: 'i-heroicons-circle-stack-20-solid',
label: 'No items.'
}
}
}

View File

@@ -1,21 +0,0 @@
export default {
wrapper: 'w-full flex flex-col',
container: 'w-full flex flex-col',
item: {
base: '',
size: 'text-sm',
color: 'text-gray-500 dark:text-gray-400',
padding: 'pt-1.5 pb-3',
icon: 'ms-auto transform transition-transform duration-200'
},
transition: {
enterActiveClass: 'overflow-hidden transition-[height] duration-200 ease-out',
leaveActiveClass: 'overflow-hidden transition-[height] duration-200 ease-out'
},
default: {
openIcon: 'i-heroicons-chevron-down-20-solid',
closeIcon: '',
class: 'mb-1.5 w-full',
variant: 'soft' as const
}
}

View File

@@ -1,40 +0,0 @@
export default {
wrapper: 'w-full relative overflow-hidden',
inner: 'w-0 flex-1',
title: 'text-sm font-medium',
description: 'mt-1 text-sm leading-4 opacity-90',
actions: 'flex items-center gap-2 mt-3 flex-shrink-0',
shadow: '',
rounded: 'rounded-lg',
padding: 'p-4',
gap: 'gap-3',
icon: {
base: 'flex-shrink-0 w-5 h-5'
},
avatar: {
base: 'flex-shrink-0 self-center',
size: 'md' as const
},
color: {
white: {
solid: 'text-gray-900 dark:text-white bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-800'
}
},
variant: {
solid: 'bg-{color}-500 dark:bg-{color}-400 text-white dark:text-gray-900',
outline: 'text-{color}-500 dark:text-{color}-400 ring-1 ring-inset ring-{color}-500 dark:ring-{color}-400',
soft: 'bg-{color}-50 dark:bg-{color}-400 dark:bg-opacity-10 text-{color}-500 dark:text-{color}-400',
subtle: 'bg-{color}-50 dark:bg-{color}-400 dark:bg-opacity-10 text-{color}-500 dark:text-{color}-400 ring-1 ring-inset ring-{color}-500 dark:ring-{color}-400 ring-opacity-25 dark:ring-opacity-25'
},
default: {
color: 'white',
variant: 'solid',
icon: null,
closeButton: null,
actionButton: {
size: 'xs' as const,
color: 'primary' as const,
variant: 'link' as const
}
}
}

View File

@@ -1,59 +0,0 @@
export default {
wrapper: 'relative inline-flex items-center justify-center flex-shrink-0',
background: 'bg-gray-100 dark:bg-gray-800',
rounded: 'rounded-full',
text: 'font-medium leading-none text-gray-900 dark:text-white truncate',
placeholder: 'font-medium leading-none text-gray-500 dark:text-gray-400 truncate',
size: {
'3xs': 'h-4 w-4 text-[8px]',
'2xs': 'h-5 w-5 text-[10px]',
xs: 'h-6 w-6 text-xs',
sm: 'h-8 w-8 text-sm',
md: 'h-10 w-10 text-base',
lg: 'h-12 w-12 text-lg',
xl: 'h-14 w-14 text-xl',
'2xl': 'h-16 w-16 text-2xl',
'3xl': 'h-20 w-20 text-3xl'
},
chip: {
base: 'absolute rounded-full ring-1 ring-white dark:ring-gray-900 flex items-center justify-center text-white dark:text-gray-900 font-medium',
background: 'bg-{color}-500 dark:bg-{color}-400',
position: {
'top-right': 'top-0 right-0',
'bottom-right': 'bottom-0 right-0',
'top-left': 'top-0 left-0',
'bottom-left': 'bottom-0 left-0'
},
size: {
'3xs': 'h-[4px] min-w-[4px] text-[4px] p-px',
'2xs': 'h-[5px] min-w-[5px] text-[5px] p-px',
xs: 'h-1.5 min-w-[0.375rem] text-[6px] p-px',
sm: 'h-2 min-w-[0.5rem] text-[7px] p-0.5',
md: 'h-2.5 min-w-[0.625rem] text-[8px] p-0.5',
lg: 'h-3 min-w-[0.75rem] text-[10px] p-0.5',
xl: 'h-3.5 min-w-[0.875rem] text-[11px] p-1',
'2xl': 'h-4 min-w-[1rem] text-[12px] p-1',
'3xl': 'h-5 min-w-[1.25rem] text-[14px] p-1'
}
},
icon: {
base: 'text-gray-500 dark:text-gray-400 flex-shrink-0',
size: {
'3xs': 'h-2 w-2',
'2xs': 'h-2.5 w-2.5',
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
xl: 'h-7 w-7',
'2xl': 'h-8 w-8',
'3xl': 'h-10 w-10'
}
},
default: {
size: 'sm',
icon: null,
chipColor: null,
chipPosition: 'top-right'
}
}

Some files were not shown because too many files have changed in this diff Show More