refactor(deps): migrate to nuxt v4

This commit is contained in:
2026-02-10 00:31:04 +08:00
parent f1b9cea060
commit 880b85f75d
88 changed files with 80 additions and 60 deletions

2
app/components/uni/Button/index.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
type ButtonType = 'normal' | 'primary' | 'danger'
type ButtonSize = 'base' | 'medium' | 'small'

View File

@@ -0,0 +1,140 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
const emit = defineEmits(['click'])
const props = defineProps({
type: {
type: String as PropType<ButtonType>,
default: 'normal',
},
attrType: {
type: String as PropType<'button' | 'submit' | 'reset'>,
default: 'button',
},
size: {
type: String as PropType<ButtonSize>,
default: 'base',
},
block: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
})
const buttonTypeClass = computed(() => {
let ret = `uni-button--normal`
if (props.type !== 'normal') ret += ` uni-button--${props.type}`
return ret
})
const buttonSizeClass = computed(() => {
return `uni-button--${props.size}`
})
const buttonIcon = computed(() => {
if (props.icon) return props.icon
return null
})
const handleClick = (e: any) => {
emit('click', e)
}
</script>
<template>
<button
class="w-fit flex justify-center items-center rounded-md font-bold border shadow-sm transition focus:ring-4"
:class="{
'w-full': block,
'uni-button--disabled': disabled || loading,
[buttonTypeClass]: buttonTypeClass,
[buttonSizeClass]: buttonSizeClass,
}"
@click="handleClick"
:disabled="disabled || loading"
:type="attrType"
>
<Transition name="icon">
<UniIconSpinner v-if="loading" />
<Icon
v-else-if="buttonIcon"
:name="buttonIcon"
:key="buttonIcon"
/>
<span
v-else
class="mr-2"
>
<slot name="icon" />
</span>
</Transition>
<div
class="flex items-center whitespace-nowrap leading-snug"
:class="{ 'ml-2': buttonIcon || loading }"
>
<slot />
</div>
</button>
</template>
<style scoped>
.icon-enter-active,
.icon-leave-active {
transition: all 0.3s ease;
}
.icon-enter-from,
.icon-leave-to {
opacity: 0;
width: 0;
}
.uni-button--normal {
@apply bg-neutral-50 hover:bg-neutral-100 active:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600;
@apply ring-neutral-200/50 dark:ring-neutral-800/50;
@apply border-neutral-300 dark:border-neutral-700;
@apply text-neutral-700 dark:text-neutral-300;
}
.uni-button--primary {
@apply text-blue-600 dark:text-blue-600;
}
.uni-button--danger {
@apply text-red-500 dark:text-red-500;
}
.uni-button--disabled {
@apply bg-neutral-100 dark:bg-neutral-900 hover:bg-neutral-100 hover:dark:bg-neutral-900;
@apply ring-transparent;
@apply border-transparent;
@apply text-neutral-400 dark:text-neutral-600;
}
.uni-button--base {
@apply text-base;
@apply px-4 py-2;
}
.uni-button--medium {
@apply text-sm;
@apply px-3 py-1.5;
}
.uni-button--small {
@apply text-sm;
@apply px-2 py-1;
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { useMessage } from '~/composables/uni/useMessage'
const props = defineProps({
hideIcon: {
type: Boolean,
default: false,
},
iconSize: {
type: String,
default: '1em',
},
text: {
type: String,
required: true,
},
})
const message = useMessage()
const copied = ref(false)
const copied_timeout = ref()
const fuck_copy = () => {
navigator.clipboard
.writeText(props.text || '')
.then(() => {
copied.value = true
if (copied_timeout.value) clearInterval(copied_timeout.value)
copied_timeout.value = setTimeout(() => (copied.value = false), 1500)
})
.catch((e) => {
message.error(`复制失败`)
})
}
</script>
<template>
<div
class="inline-flex items-center gap-0.5 cursor-pointer"
@click="fuck_copy"
>
<slot />
<Transition
v-if="!hideIcon"
name="icon"
mode="out-in"
>
<svg
v-if="!copied"
xmlns="http://www.w3.org/2000/svg"
:width="iconSize"
:height="iconSize"
viewBox="0 0 24 24"
class="text-neutral-500"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path
d="M8 10a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2z"
/>
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
</g>
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
:width="iconSize"
:height="iconSize"
viewBox="0 0 24 24"
class="text-green-600"
>
<defs>
<mask id="lineMdCheckAll0">
<g
fill="none"
stroke="#fff"
stroke-dasharray="22"
stroke-dashoffset="22"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M2 13.5l4 4l10.75 -10.75">
<animate
fill="freeze"
attributeName="stroke-dashoffset"
dur="0.2s"
values="22;0"
/>
</path>
<path
stroke="#000"
stroke-width="4"
d="M7.5 13.5l4 4l10.75 -10.75"
opacity="0"
>
<set
attributeName="opacity"
begin="0.2s"
to="1"
/>
<animate
fill="freeze"
attributeName="stroke-dashoffset"
begin="0.2s"
dur="0.2s"
values="22;0"
/>
</path>
<path
d="M7.5 13.5l4 4l10.75 -10.75"
opacity="0"
>
<set
attributeName="opacity"
begin="0.2s"
to="1"
/>
<animate
fill="freeze"
attributeName="stroke-dashoffset"
begin="0.2s"
dur="0.2s"
values="22;0"
/>
</path>
</g>
</mask>
</defs>
<rect
width="24"
height="24"
fill="currentColor"
mask="url(#lineMdCheckAll0)"
/>
</svg>
</Transition>
</div>
</template>
<style scoped>
.icon-enter-active,
.icon-leave-active {
@apply transition duration-300;
}
.icon-enter-from,
.icon-leave-to {
@apply opacity-0;
}
</style>

View File

@@ -0,0 +1,134 @@
<script lang="ts" setup>
const emit = defineEmits(['change'])
const props = defineProps({
placeholder: {
type: String,
default: '点击或拖拽文件到此处',
},
placeholderDragover: {
type: String,
default: '松开选择文件',
},
multiple: {
type: Boolean,
default: false,
},
accept: {
type: String,
default: '',
},
})
const inputRef = ref<HTMLInputElement | null>(null)
const dragover = ref(false)
const selectedFiles = ref<File[]>([])
const onIncomeFiles = (files?: FileList | null) => {
if (files && files.length > 0) {
let wantedFiles = Array.from(files).filter((file) => {
if (props.accept) {
const accept = props.accept.split(',').map((type) => type.trim())
return accept.includes(file.type)
}
return true
})
if (wantedFiles.length === 0) {
console.error('no acceptable file')
return
}
selectedFiles.value = props.multiple ? wantedFiles : [wantedFiles[0]]
emit('change', files)
}
}
</script>
<template>
<div
:class="{
'bg-neutral-300 dark:bg-neutral-900 border-primary-300 dark:border-primary-800 shadow-inner':
dragover,
}"
class="w-full h-44 relative rounded-md border-2 border-dashed border-neutral-200 dark:border-neutral-800 bg-inherit cursor-pointer select-none transition duration-200 hover:border-primary-300 dark:hover:border-primary-800 overflow-hidden"
@click="inputRef?.click()"
@dragover.prevent="dragover = true"
@dragleave.prevent="dragover = false"
@drop.prevent="
($event) => {
dragover = false
if (!$event.dataTransfer?.files) return
onIncomeFiles($event.dataTransfer?.files)
}
"
>
<input
ref="inputRef"
:accept="accept"
:multiple="multiple"
class="hidden"
type="file"
@change="onIncomeFiles(inputRef?.files)"
/>
<div
:class="{
'pb-6': selectedFiles.length > 0,
}"
class="w-full h-full flex flex-col justify-center items-center gap-2 transition-all"
>
<Icon
:name="dragover ? 'i-tabler-drag-drop' : 'i-tabler-upload'"
class="text-4xl text-neutral-400 dark:text-neutral-500"
/>
<p class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
{{ dragover ? '松开选择文件' : '点击或拖拽文件到此处' }}
</p>
</div>
<div
v-if="selectedFiles.length > 0"
class="absolute inset-x-0 bottom-0 pl-2 pr-0.5 py-0.5 flex justify-between items-center bg-neutral-100 dark:bg-neutral-900 border-t dark:border-neutral-800"
>
<div class="flex-1 pr-4 overflow-hidden flex items-center gap-1">
<Icon
:name="
selectedFiles.length === 1 ? 'i-tabler-file' : 'i-tabler-files'
"
class="text-neutral-500 dark:text-neutral-400"
/>
<p
:title="
selectedFiles
.slice(0, 3)
.map((file) => file.name)
.join(', ')
"
class="text-2xs font-medium overflow-hidden text-ellipsis whitespace-nowrap"
>
{{
selectedFiles
.slice(0, 3)
.map((file) => file.name)
.join(', ')
}}
</p>
</div>
<div>
<UButton
color="red"
size="xs"
square
variant="ghost"
@click.stop="
() => {
selectedFiles = []
inputRef!.value = ''
}
"
>
<Icon name="i-tabler-x" />
</UButton>
</div>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,21 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<g
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M0 0h24v24H0z"></path>
<path
fill="currentColor"
d="M17 3.34a10 10 0 1 1-14.995 8.984L2 12l.005-.324A10 10 0 0 1 17 3.34zm-6.489 5.8a1 1 0 0 0-1.218 1.567L10.585 12l-1.292 1.293l-.083.094a1 1 0 0 0 1.497 1.32L12 13.415l1.293 1.292l.094.083a1 1 0 0 0 1.32-1.497L13.415 12l1.292-1.293l.083-.094a1 1 0 0 0-1.497-1.32L12 10.585l-1.293-1.292l-.094-.083z"
></path>
</g>
</svg>
</template>

View File

@@ -0,0 +1,15 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10Zm-10 5.75a.75.75 0 0 0 .75-.75v-6a.75.75 0 0 0-1.5 0v6c0 .414.336.75.75.75ZM12 7a1 1 0 1 1 0 2a1 1 0 0 1 0-2Z"
clipRule="evenodd"
></path>
</svg>
</template>

View File

@@ -0,0 +1,21 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<g
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M0 0h24v24H0z"></path>
<path
fill="currentColor"
d="M17 3.34a10 10 0 1 1-14.995 8.984L2 12l.005-.324A10 10 0 0 1 17 3.34zm-1.293 5.953a1 1 0 0 0-1.32-.083l-.094.083L11 12.585l-1.293-1.292l-.094-.083a1 1 0 0 0-1.403 1.403l.083.094l2 2l.094.083a1 1 0 0 0 1.226 0l.094-.083l4-4l.083-.094a1 1 0 0 0-.083-1.32z"
></path>
</g>
</svg>
</template>

View File

@@ -0,0 +1,21 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<g
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M0 0h24v24H0z"></path>
<path
fill="currentColor"
d="M12 2c5.523 0 10 4.477 10 10a10 10 0 0 1-19.995.324L2 12l.004-.28C2.152 6.327 6.57 2 12 2zm.01 13l-.127.007a1 1 0 0 0 0 1.986L12 17l.127-.007a1 1 0 0 0 0-1.986L12.01 15zM12 7a1 1 0 0 0-.993.883L11 8v4l.007.117a1 1 0 0 0 1.986 0L13 12V8l-.007-.117A1 1 0 0 0 12 7z"
></path>
</g>
</svg>
</template>

View File

@@ -0,0 +1,26 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
></path>
<path
fill="currentColor"
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z"
>
<animateTransform
attributeName="transform"
dur="0.75s"
repeatCount="indefinite"
type="rotate"
values="0 12 12;360 12 12"
></animateTransform>
</path>
</svg>
</template>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
const emit = defineEmits(['input', 'change', 'update:modelValue'])
const props = defineProps({
label: {
type: String,
required: false,
default: '',
},
modelValue: {
type: [String, Number] as PropType<string | number | undefined>,
required: true,
},
placeholder: {
type: String,
required: false,
default: '',
},
type: {
type: String as PropType<
'text' | 'password' | 'number' | 'email' | 'tel' | 'date'
>,
required: false,
default: 'text',
},
justify: {
type: String as PropType<'start' | 'end'>,
required: false,
default: 'end',
},
pattern: {
type: [String, RegExp],
required: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
})
const inputValue = ref(props.modelValue)
const isError = ref(false)
watch(
() => props.modelValue,
(value) => {
inputValue.value = value
if (props.pattern && value) {
const pattern =
typeof props.pattern === 'string'
? new RegExp(props.pattern)
: props.pattern
isError.value = !pattern.test(value as string)
pattern.lastIndex = 0
}
},
{ immediate: true }
)
const handleInput = (e: any) => {
if (props.disabled) return
const value = e.target.value
if (props.pattern && value && props.type !== 'date') {
const pattern =
typeof props.pattern === 'string'
? new RegExp(props.pattern)
: props.pattern
isError.value = !pattern.test(value)
pattern.lastIndex = 0
inputValue.value = value
if (isError.value) return
}
inputValue.value = value
isError.value = false
emit('update:modelValue', e.target.value)
emit('input', e)
}
</script>
<template>
<div
class="flex flex-col space-y-1"
:class="{
'justify-start': justify === 'start',
'justify-end': justify === 'end',
}"
>
<p
class="block w-fit text-neutral-700 dark:text-neutral-300 text-sm font-bold font-['Nunito']"
v-if="label"
>
{{ label }}
</p>
<div class="relative">
<input
class="relative w-full flex items-center gap-2.5 p-2 pr-2 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800 outline-none placeholder-neutral-400 dark:placeholder-neutral-500 shadow-sm"
:class="{
'!border-red-500': isError,
'bg-neutral-100 dark:bg-neutral-900 text-neutral-400 dark:text-neutral-600':
disabled,
}"
:value="inputValue"
@input="handleInput"
:placeholder="placeholder"
:disabled="disabled"
:type="type"
/>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,113 @@
<script lang="ts" setup>
import type {
Message,
MessageApi,
MessageProviderApi,
MessageType,
} from '~/components/uni/Message/index'
const props = defineProps({
max: {
type: Number,
default: 5,
},
})
const nuxtApp = useNuxtApp()
const messageList = ref<Message[]>([])
const createMessage = (
content: string,
type: MessageType,
duration: number = 3000
) => {
const { max } = props
messageList.value.push({
id: (Date.now() + Math.random() * 100).toString(32).toUpperCase(),
content,
type,
duration,
})
if (messageList.value.length > max) {
messageList.value.shift()
}
}
const providerApi: MessageProviderApi = {
destroy: (id: string) => {
messageList.value.splice(
messageList.value.findIndex((message) => message.id === id),
1
)
},
}
const api: MessageApi = {
info: (content: string, duration: number = 3000) => {
createMessage(content, 'info', duration)
},
success: (content: string, duration: number = 3000) => {
createMessage(content, 'success', duration)
},
warning: (content: string, duration: number = 3000) => {
createMessage(content, 'warning', duration)
},
error: (content: string, duration: number = 3000) => {
createMessage(content, 'error', duration)
},
destroyAll: function (): void {
throw new Error('Function not implemented.')
},
}
nuxtApp.vueApp.provide('uni-message-provider', providerApi)
nuxtApp.vueApp.provide('uni-message', api)
</script>
<template>
<slot />
<teleport to="body">
<div id="message-provider">
<div class="message-wrapper">
<TransitionGroup name="message">
<UniMessage
v-for="(message, k) in messageList"
:key="message.id"
:message="message"
/>
</TransitionGroup>
</div>
</div>
</teleport>
</template>
<style scoped>
#message-provider .message-wrapper {
@apply z-[50000] fixed inset-0 flex flex-col items-center pointer-events-none;
}
.message-move,
.message-leave-active {
transition: all 0.6s ease;
}
.message-enter-active {
transition: all 0.6s cubic-bezier(0.075, 0.82, 0.165, 1);
}
.message-enter-from {
filter: blur(2px);
opacity: 0;
transform: translateY(-100%);
}
.message-leave-to {
filter: blur(6px);
opacity: 0;
transform: translateY(-20%);
}
.message-leave-active {
position: absolute;
}
</style>

20
app/components/uni/Message/index.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
export type Message = {
id: string
content: string
type: MessageType
duration?: number
}
export type MessageType = 'success' | 'warning' | 'error' | 'info'
export type MessageProviderApi = {
destroy: (id: string) => void
}
export type MessageApi = {
info: (content: string, duration?: number) => void
success: (content: string, duration?: number) => void
warning: (content: string, duration?: number) => void
error: (content: string, duration?: number) => void
destroyAll: () => void
}

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import type {
Message,
MessageProviderApi,
} from '~/components/uni/Message/index'
const providerApi = inject<MessageProviderApi>('uni-message-provider')
const props = defineProps({
message: {
require: true,
type: Object,
},
})
const message = ref<Message>(props.message as Message)
onMounted(() => {
setTimeout(() => {
providerApi?.destroy(message.value.id)
}, message.value?.duration || 3000)
})
</script>
<template>
<div
class="message"
:class="{
'!text-blue-500 !border-blue-400 !bg-blue-50': message.type === 'info',
'!text-emerald-500 !border-emerald-400 !bg-emerald-50':
message.type === 'success',
'!text-orange-500 !border-orange-400 !bg-orange-50':
message.type === 'warning',
'!text-rose-500 !border-rose-400 !bg-rose-50': message.type === 'error',
[message.type]: message.type,
}"
>
<UniIconCircleSuccess
v-if="message.type === 'success'"
class="text-xl"
/>
<UniIconCircleWarning
v-if="message.type === 'warning'"
class="text-xl"
/>
<UniIconCircleError
v-if="message.type === 'error'"
class="text-xl"
/>
<UniIconCircleInfo
v-if="message.type === 'info'"
class="text-xl"
/>
<span>
{{ message.content }}
</span>
</div>
</template>
<style scoped>
.message {
min-width: 80px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
@apply h-fit px-2 py-1.5 border bg-white border-gray-300 rounded-md text-gray-500 text-xs flex items-center gap-1.5 first-of-type:mt-2.5 mt-2.5 font-bold pointer-events-auto;
}
.message.info {
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.message.success {
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
}
.message.warning {
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.2);
}
.message.error {
box-shadow: 0 4px 12px rgba(244, 63, 94, 0.2);
}
</style>

6
app/components/uni/Select/index.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
type SelectItem = {
label: string
value: string
icon?: string
disabled?: boolean
}

View File

@@ -0,0 +1,187 @@
<script lang="ts" setup>
import { computed, type PropType } from 'vue'
const emit = defineEmits(['input', 'change', 'update:modelValue'])
const props = defineProps({
label: {
type: String,
required: false,
default: '',
},
modelValue: {
type: [String, Number],
required: false,
},
items: {
type: Array as PropType<SelectItem[]>,
required: true,
},
justify: {
type: String as PropType<'start' | 'end'>,
required: false,
default: 'end',
},
disabled: {
type: Boolean,
required: false,
default: false,
},
align: {
type: String as PropType<'bottom' | 'top'>,
required: false,
default: 'bottom',
},
})
const selectWrapperRef = ref()
const selectRef = ref()
const optionsRef = ref()
const optionsAlign = computed(() => {
switch (props.align) {
case 'bottom':
return 'top-full mt-2'
case 'top':
return 'bottom-full mb-2'
}
})
const hasAnyIcon = computed(() => props.items.some((item) => item.icon))
const selectedItem = computed(
() =>
props.items.find((item) => item.value === props.modelValue) as SelectItem
)
const optionsExpanded = ref(false)
const selectedIconFlag = ref(true)
const handleSelectClick = () => {
optionsExpanded.value = !optionsExpanded.value
}
const handleOptionSelect = (option: SelectItem) => {
emit('input', option.value)
emit('change', option.value)
emit('update:modelValue', option.value)
selectedIconFlag.value = false
nextTick(() => {
selectedIconFlag.value = true
})
}
onMounted(() => {
selectRef.value.ownerDocument.addEventListener(
'click',
(e: { target: any }) => {
if (optionsExpanded && !selectRef?.value?.contains(e.target)) {
optionsExpanded.value = false
}
}
)
})
</script>
<template>
<div
class="flex flex-col space-y-1"
:class="{
'justify-start': justify === 'start',
'justify-end': justify === 'end',
}"
>
<p
class="block w-fit text-neutral-700 dark:text-neutral-300 text-sm font-bold font-['Nunito']"
v-if="label"
>
{{ label }}
</p>
<div
class="relative"
ref="selectWrapperRef"
>
<button
class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800 shadow-sm"
:class="{
'cursor-not-allowed bg-neutral-100 dark:bg-neutral-900 text-neutral-400 dark:text-neutral-600':
disabled,
}"
ref="selectRef"
type="button"
@click="handleSelectClick"
:disabled="disabled"
>
<span
v-if="selectedItem?.icon && !selectedIconFlag && hasAnyIcon"
class="inline-block w-5 h-5 pointer-events-none"
></span>
<Icon
v-else-if="selectedItem?.icon && selectedIconFlag && hasAnyIcon"
:name="selectedItem?.icon"
class="inline-block w-5 h-5 pointer-events-none"
/>
<Transition
name="select-item"
mode="out-in"
>
<span
class="leading-snug whitespace-nowrap text-sm"
:key="selectedItem?.value"
>
{{
selectedItem?.label || selectedItem?.value || 'Select an option'
}}
</span>
</Transition>
<Icon
name="tabler:dots-vertical"
class="absolute bg-neutral-50 text-gray-500 dark:bg-neutral-700/50 dark:text-neutral-500 inset-y-0 right-0 h-full"
/>
</button>
<div
class="absolute right-0 w-full md:w-fit rounded-md border overflow-x-hidden overflow-y-auto transition shadow-lg opacity-0 bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 z-50 max-h-64"
:class="{
'opacity-100 pointer-events-auto': optionsExpanded,
'-translate-y-4 pointer-events-none': !optionsExpanded,
[optionsAlign]: optionsAlign,
}"
ref="optionsRef"
>
<div
class="flex items-center gap-2.5 px-2 py-2 cursor-pointer dark:text-neutral-300 font-['Nunito'] transition whitespace-nowrap bg-white dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-700"
v-for="(option, index) in items"
:key="index"
:class="{
'!bg-neutral-200 dark:!bg-neutral-700 hover:!bg-neutral-200 dark:hover:!bg-neutral-700':
option.value === selectedItem?.value,
'!cursor-not-allowed text-neutral-300 dark:text-neutral-500 hover:bg-white dark:hover:!bg-neutral-800':
option.disabled,
}"
@click="!option.disabled ? handleOptionSelect(option) : void 0"
>
<div
class="inline-block w-5 h-5"
v-if="hasAnyIcon && !option.icon"
></div>
<Icon
:name="option?.icon"
class="inline-block w-5 h-5"
v-if="option.icon"
/>
<span class="leading-none whitespace-nowrap text-sm font-sans">
{{ option.label || 'No label' }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.select-item-enter-active,
.select-item-leave-active {
transition: all 0.15s ease;
}
.select-item-enter-from,
.select-item-leave-to {
opacity: 0.5;
filter: blur(2px);
}
</style>

View File

@@ -0,0 +1,136 @@
import { textarea } from '@nuxt/ui';
<script lang="ts" setup>
const emit = defineEmits(['input', 'change', 'update:modelValue'])
const props = defineProps({
label: {
type: String,
required: false,
default: '',
},
modelValue: {
type: [String, Number],
required: true,
},
placeholder: {
type: String,
required: false,
default: '',
},
justify: {
type: String as PropType<'start' | 'end'>,
required: false,
default: 'end',
},
pattern: {
type: [String, RegExp],
required: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
rows: {
type: Number,
required: false,
default: 5,
},
minRows: {
type: Number,
required: false,
},
})
const textAreaRef = ref()
const inputValue = ref(props.modelValue)
const isError = ref(false)
watch(
() => props.modelValue,
(value) => {
inputValue.value = value
if (props.pattern && value) {
const pattern =
typeof props.pattern === 'string'
? new RegExp(props.pattern)
: props.pattern
isError.value = !pattern.test(value as string)
pattern.lastIndex = 0
}
},
{ immediate: true }
)
const handleInput = (e: any) => {
if (props.disabled) return
const value = e.target.value
if (props.pattern && value) {
const pattern =
typeof props.pattern === 'string'
? new RegExp(props.pattern)
: props.pattern
isError.value = !pattern.test(value)
pattern.lastIndex = 0
inputValue.value = value
if (isError.value) return
}
inputValue.value = value
isError.value = false
emit('update:modelValue', e.target.value)
emit('input', e)
}
const autosize = (e: any) => {
const el = e?.target ? e.target : e
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
onMounted(() => {
if (props.minRows) {
const textarea = textAreaRef.value
textarea?.addEventListener('keydown', autosize)
textarea?.addEventListener('input', autosize)
textarea?.addEventListener('focus', autosize)
autosize(textarea)
}
})
</script>
<template>
<div
class="flex flex-col space-y-1"
:class="{
'justify-start': justify === 'start',
'justify-end': justify === 'end',
}"
>
<p
class="block w-fit text-neutral-700 dark:text-neutral-300 text-sm font-bold font-['Nunito']"
v-if="label"
>
{{ label }}
</p>
<div class="relative">
<textarea
class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden overflow-y-auto border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800 outline-none placeholder-neutral-400 dark:placeholder-neutral-500 shadow-sm"
:rows="minRows || rows"
ref="textAreaRef"
:class="{
'!border-red-500': isError,
'bg-neutral-100 dark:bg-neutral-900 text-neutral-400 dark:text-neutral-600':
disabled,
}"
:value="inputValue"
@input="handleInput"
:placeholder="placeholder"
:disabled="disabled"
/>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import type { PropType } from 'vue'
const emit = defineEmits(['input', 'change', 'update:modelValue'])
const props = defineProps({
modelValue: {
type: Boolean,
required: false,
},
size: {
type: String as PropType<'sm' | 'md' | 'lg'>,
required: false,
default: 'md',
},
value: {
type: Boolean,
required: false,
},
onIcon: {
type: String,
required: false,
},
offIcon: {
type: String,
required: false,
},
})
const checked = ref(false)
const buttonSizeClass = computed(() => {
switch (props.size) {
case 'sm':
return 'h-6 w-10'
case 'md':
return 'h-8 w-14'
case 'lg':
return 'h-10 w-[calc(2.5rem/0.54)]'
}
})
const buttonPaddingClass = computed(() => {
switch (props.size) {
case 'sm':
return 'p-1'
case 'md':
return 'p-1'
case 'lg':
return 'p-1.5'
}
})
const bulletSizeClass = computed(() => {
switch (props.size) {
case 'sm':
return 'h-4'
case 'md':
return 'h-6'
case 'lg':
return 'h-7'
}
})
const bulletTranslateClass = computed(() => {
switch (props.size) {
case 'sm':
return 'translate-x-4'
case 'md':
return 'translate-x-6'
case 'lg':
return 'translate-x-8'
}
})
const handleCheck = () => {
checked.value = !checked.value
emit('update:modelValue', checked.value)
emit('change', checked.value)
emit('input', checked.value)
}
onMounted(() => {
if (props.modelValue) {
checked.value = props.modelValue
} else if (props.value) {
checked.value = props.value
}
})
watch(
() => props.modelValue,
(value) => {
checked.value = value
}
)
</script>
<template>
<button
class="relative flex items-center rounded-lg bg-neutral-100 dark:bg-neutral-800 shadow-inner transition ease-in-out group outline-none"
:class="{
'!bg-green-400 dark:!bg-green-400/50': checked,
[buttonSizeClass]: buttonSizeClass,
[buttonPaddingClass]: buttonPaddingClass,
}"
@click="handleCheck"
>
<span
class="aspect-[1/1] translate-x-0 transition ease-in-out bg-white dark:bg-black rounded-md shadow duration-300 group-active:scale-90"
:class="{
'!shadow-lg': checked,
'group-active:translate-x-3 group-active:duration-500': !checked,
[bulletSizeClass]: bulletSizeClass,
[bulletTranslateClass]: checked,
}"
>
<span
v-if="onIcon || offIcon"
class="absolute inset-0 flex items-center justify-center text-neutral-400"
>
<Transition
name="icon"
mode="out-in"
>
<slot
v-if="checked"
name="on-icon"
/>
<slot
v-else
name="off-icon"
/>
</Transition>
</span>
</span>
</button>
</template>
<style scoped>
.icon-enter-active,
.icon-leave-active {
transition: all 0.1s ease;
}
.icon-enter-from,
.icon-leave-to {
opacity: 0;
transform: scale(0.5);
}
</style>