mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-22 07:50:36 +01:00
remove old files
This commit is contained in:
285
src/colors.ts
285
src/colors.ts
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 || ' ' }}</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">·</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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
9
src/runtime/types/accordion.d.ts
vendored
9
src/runtime/types/accordion.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
import type { Button } from './button'
|
||||
|
||||
export interface AccordionItem extends Button {
|
||||
slot?: string
|
||||
disabled?: boolean
|
||||
content?: string
|
||||
defaultOpen?: boolean
|
||||
closeOthers?: boolean
|
||||
}
|
||||
12
src/runtime/types/alert.d.ts
vendored
12
src/runtime/types/alert.d.ts
vendored
@@ -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
|
||||
}
|
||||
17
src/runtime/types/avatar.d.ts
vendored
17
src/runtime/types/avatar.d.ts
vendored
@@ -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
|
||||
}
|
||||
15
src/runtime/types/badge.d.ts
vendored
15
src/runtime/types/badge.d.ts
vendored
@@ -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
|
||||
}
|
||||
10
src/runtime/types/breadcrumb.d.ts
vendored
10
src/runtime/types/breadcrumb.d.ts
vendored
@@ -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
|
||||
}
|
||||
31
src/runtime/types/button.d.ts
vendored
31
src/runtime/types/button.d.ts
vendored
@@ -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
|
||||
}
|
||||
15
src/runtime/types/chip.d.ts
vendored
15
src/runtime/types/chip.d.ts
vendored
@@ -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
|
||||
}
|
||||
3
src/runtime/types/clipboard.d.ts
vendored
3
src/runtime/types/clipboard.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
export interface ClipboardPlugin {
|
||||
copy: (text: string, success?: { title?: string, description?: string }, failure?: { title?: string, description?: string }) => void
|
||||
}
|
||||
28
src/runtime/types/command-palette.d.ts
vendored
28
src/runtime/types/command-palette.d.ts
vendored
@@ -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
|
||||
}
|
||||
14
src/runtime/types/component.d.ts
vendored
14
src/runtime/types/component.d.ts
vendored
@@ -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> :
|
||||
{}
|
||||
3
src/runtime/types/divider.d.ts
vendored
3
src/runtime/types/divider.d.ts
vendored
@@ -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
|
||||
15
src/runtime/types/dropdown.d.ts
vendored
15
src/runtime/types/dropdown.d.ts
vendored
@@ -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
|
||||
}
|
||||
5
src/runtime/types/form-group.d.ts
vendored
5
src/runtime/types/form-group.d.ts
vendored
@@ -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']>
|
||||
13
src/runtime/types/horizontal-navigation.d.ts
vendored
13
src/runtime/types/horizontal-navigation.d.ts
vendored
@@ -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
|
||||
}
|
||||
8
src/runtime/types/input.d.ts
vendored
8
src/runtime/types/input.d.ts
vendored
@@ -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']>>
|
||||
5
src/runtime/types/kbd.d.ts
vendored
5
src/runtime/types/kbd.d.ts
vendored
@@ -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']>
|
||||
12
src/runtime/types/link.d.ts
vendored
12
src/runtime/types/link.d.ts
vendored
@@ -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
|
||||
}
|
||||
5
src/runtime/types/meter.d.ts
vendored
5
src/runtime/types/meter.d.ts
vendored
@@ -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]
|
||||
18
src/runtime/types/modal.d.ts
vendored
18
src/runtime/types/modal.d.ts
vendored
@@ -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
|
||||
}
|
||||
24
src/runtime/types/notification.d.ts
vendored
24
src/runtime/types/notification.d.ts
vendored
@@ -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
|
||||
}
|
||||
17
src/runtime/types/popper.d.ts
vendored
17
src/runtime/types/popper.d.ts
vendored
@@ -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
|
||||
}
|
||||
6
src/runtime/types/progress.d.ts
vendored
6
src/runtime/types/progress.d.ts
vendored
@@ -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]
|
||||
7
src/runtime/types/range.d.ts
vendored
7
src/runtime/types/range.d.ts
vendored
@@ -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]
|
||||
8
src/runtime/types/select.d.ts
vendored
8
src/runtime/types/select.d.ts
vendored
@@ -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']>>
|
||||
17
src/runtime/types/slideover.d.ts
vendored
17
src/runtime/types/slideover.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
7
src/runtime/types/tabs.d.ts
vendored
7
src/runtime/types/tabs.d.ts
vendored
@@ -1,7 +0,0 @@
|
||||
export interface TabItem {
|
||||
label: string
|
||||
slot?: string
|
||||
disabled?: boolean
|
||||
content?: string
|
||||
[key: string]: any
|
||||
}
|
||||
8
src/runtime/types/textarea.d.ts
vendored
8
src/runtime/types/textarea.d.ts
vendored
@@ -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']>>
|
||||
7
src/runtime/types/toggle.d.ts
vendored
7
src/runtime/types/toggle.d.ts
vendored
@@ -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]
|
||||
7
src/runtime/types/tooltip.d.ts
vendored
7
src/runtime/types/tooltip.d.ts
vendored
@@ -1,7 +0,0 @@
|
||||
export interface Tooltip {
|
||||
text?: string
|
||||
prevent?: boolean
|
||||
shortcuts?: string[]
|
||||
openDelay?: number
|
||||
closeDelay?: number
|
||||
}
|
||||
28
src/runtime/types/utils.d.ts
vendored
28
src/runtime/types/utils.d.ts
vendored
@@ -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
|
||||
13
src/runtime/types/vertical-navigation.d.ts
vendored
13
src/runtime/types/vertical-navigation.d.ts
vendored
@@ -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
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user