feat: new ui
This commit is contained in:
parent
2bea72596e
commit
522cf7dfb9
43
components.d.ts
vendored
43
components.d.ts
vendored
@ -7,9 +7,25 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Accordion: typeof import('./src/components/ui/accordion/Accordion.vue')['default']
|
||||
AccordionContent: typeof import('./src/components/ui/accordion/AccordionContent.vue')['default']
|
||||
AccordionItem: typeof import('./src/components/ui/accordion/AccordionItem.vue')['default']
|
||||
AccordionTrigger: typeof import('./src/components/ui/accordion/AccordionTrigger.vue')['default']
|
||||
AutoForm: typeof import('./src/components/ui/auto-form/AutoForm.vue')['default']
|
||||
AutoFormField: typeof import('./src/components/ui/auto-form/AutoFormField.vue')['default']
|
||||
AutoFormFieldArray: typeof import('./src/components/ui/auto-form/AutoFormFieldArray.vue')['default']
|
||||
AutoFormFieldBoolean: typeof import('./src/components/ui/auto-form/AutoFormFieldBoolean.vue')['default']
|
||||
AutoFormFieldDate: typeof import('./src/components/ui/auto-form/AutoFormFieldDate.vue')['default']
|
||||
AutoFormFieldEnum: typeof import('./src/components/ui/auto-form/AutoFormFieldEnum.vue')['default']
|
||||
AutoFormFieldFile: typeof import('./src/components/ui/auto-form/AutoFormFieldFile.vue')['default']
|
||||
AutoFormFieldInput: typeof import('./src/components/ui/auto-form/AutoFormFieldInput.vue')['default']
|
||||
AutoFormFieldNumber: typeof import('./src/components/ui/auto-form/AutoFormFieldNumber.vue')['default']
|
||||
AutoFormFieldObject: typeof import('./src/components/ui/auto-form/AutoFormFieldObject.vue')['default']
|
||||
AutoFormLabel: typeof import('./src/components/ui/auto-form/AutoFormLabel.vue')['default']
|
||||
Avatar: typeof import('./src/components/ui/avatar/Avatar.vue')['default']
|
||||
AvatarFallback: typeof import('./src/components/ui/avatar/AvatarFallback.vue')['default']
|
||||
AvatarImage: typeof import('./src/components/ui/avatar/AvatarImage.vue')['default']
|
||||
Badge: typeof import('./src/components/ui/badge/Badge.vue')['default']
|
||||
Breadcrumb: typeof import('./src/components/ui/breadcrumb/Breadcrumb.vue')['default']
|
||||
BreadcrumbEllipsis: typeof import('./src/components/ui/breadcrumb/BreadcrumbEllipsis.vue')['default']
|
||||
BreadcrumbItem: typeof import('./src/components/ui/breadcrumb/BreadcrumbItem.vue')['default']
|
||||
@ -18,6 +34,19 @@ declare module 'vue' {
|
||||
BreadcrumbPage: typeof import('./src/components/ui/breadcrumb/BreadcrumbPage.vue')['default']
|
||||
BreadcrumbSeparator: typeof import('./src/components/ui/breadcrumb/BreadcrumbSeparator.vue')['default']
|
||||
Button: typeof import('./src/components/ui/button/Button.vue')['default']
|
||||
Calendar: typeof import('./src/components/ui/calendar/Calendar.vue')['default']
|
||||
CalendarCell: typeof import('./src/components/ui/calendar/CalendarCell.vue')['default']
|
||||
CalendarCellTrigger: typeof import('./src/components/ui/calendar/CalendarCellTrigger.vue')['default']
|
||||
CalendarGrid: typeof import('./src/components/ui/calendar/CalendarGrid.vue')['default']
|
||||
CalendarGridBody: typeof import('./src/components/ui/calendar/CalendarGridBody.vue')['default']
|
||||
CalendarGridHead: typeof import('./src/components/ui/calendar/CalendarGridHead.vue')['default']
|
||||
CalendarGridRow: typeof import('./src/components/ui/calendar/CalendarGridRow.vue')['default']
|
||||
CalendarHeadCell: typeof import('./src/components/ui/calendar/CalendarHeadCell.vue')['default']
|
||||
CalendarHeader: typeof import('./src/components/ui/calendar/CalendarHeader.vue')['default']
|
||||
CalendarHeading: typeof import('./src/components/ui/calendar/CalendarHeading.vue')['default']
|
||||
CalendarNextButton: typeof import('./src/components/ui/calendar/CalendarNextButton.vue')['default']
|
||||
CalendarPrevButton: typeof import('./src/components/ui/calendar/CalendarPrevButton.vue')['default']
|
||||
Checkbox: typeof import('./src/components/ui/checkbox/Checkbox.vue')['default']
|
||||
Collapsible: typeof import('./src/components/ui/collapsible/Collapsible.vue')['default']
|
||||
CollapsibleContent: typeof import('./src/components/ui/collapsible/CollapsibleContent.vue')['default']
|
||||
CollapsibleTrigger: typeof import('./src/components/ui/collapsible/CollapsibleTrigger.vue')['default']
|
||||
@ -35,7 +64,13 @@ declare module 'vue' {
|
||||
DropdownMenuSubContent: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubContent.vue')['default']
|
||||
DropdownMenuSubTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue')['default']
|
||||
DropdownMenuTrigger: typeof import('./src/components/ui/dropdown-menu/DropdownMenuTrigger.vue')['default']
|
||||
FormControl: typeof import('./src/components/ui/form/FormControl.vue')['default']
|
||||
FormDescription: typeof import('./src/components/ui/form/FormDescription.vue')['default']
|
||||
FormItem: typeof import('./src/components/ui/form/FormItem.vue')['default']
|
||||
FormLabel: typeof import('./src/components/ui/form/FormLabel.vue')['default']
|
||||
FormMessage: typeof import('./src/components/ui/form/FormMessage.vue')['default']
|
||||
Image: typeof import('./src/components/Image.vue')['default']
|
||||
ImageContainer: typeof import('./src/components/ImageContainer.vue')['default']
|
||||
Input: typeof import('./src/components/ui/input/Input.vue')['default']
|
||||
Label: typeof import('./src/components/ui/label/Label.vue')['default']
|
||||
Loading: typeof import('./src/components/Loading.vue')['default']
|
||||
@ -53,6 +88,12 @@ declare module 'vue' {
|
||||
NumberFieldDecrement: typeof import('./src/components/ui/number-field/NumberFieldDecrement.vue')['default']
|
||||
NumberFieldIncrement: typeof import('./src/components/ui/number-field/NumberFieldIncrement.vue')['default']
|
||||
NumberFieldInput: typeof import('./src/components/ui/number-field/NumberFieldInput.vue')['default']
|
||||
ParamsCard: typeof import('./src/components/ParamsCard.vue')['default']
|
||||
Popover: typeof import('./src/components/ui/popover/Popover.vue')['default']
|
||||
PopoverContent: typeof import('./src/components/ui/popover/PopoverContent.vue')['default']
|
||||
PopoverTrigger: typeof import('./src/components/ui/popover/PopoverTrigger.vue')['default']
|
||||
RadioGroup: typeof import('./src/components/ui/radio-group/RadioGroup.vue')['default']
|
||||
RadioGroupItem: typeof import('./src/components/ui/radio-group/RadioGroupItem.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('./src/components/ui/select/Select.vue')['default']
|
||||
@ -100,7 +141,9 @@ declare module 'vue' {
|
||||
SidebarSeparator: typeof import('./src/components/ui/sidebar/SidebarSeparator.vue')['default']
|
||||
SidebarTrigger: typeof import('./src/components/ui/sidebar/SidebarTrigger.vue')['default']
|
||||
Skeleton: typeof import('./src/components/ui/skeleton/Skeleton.vue')['default']
|
||||
Switch: typeof import('./src/components/ui/switch/Switch.vue')['default']
|
||||
TestHeader: typeof import('./src/components/TestHeader.vue')['default']
|
||||
Textarea: typeof import('./src/components/ui/textarea/Textarea.vue')['default']
|
||||
TheFooter: typeof import('./src/components/TheFooter.vue')['default']
|
||||
TheHeader: typeof import('./src/components/TheHeader.vue')['default']
|
||||
Tooltip: typeof import('./src/components/ui/tooltip/Tooltip.vue')['default']
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@unocss/preset-icons": "^0.65.4",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -22,8 +23,10 @@
|
||||
"shadcn-vue": "^0.11.3",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.12.0",
|
||||
|
||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@ -15,6 +15,9 @@ importers:
|
||||
'@unocss/preset-icons':
|
||||
specifier: ^0.65.4
|
||||
version: 0.65.4
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.0
|
||||
version: 4.15.0(vue@3.5.13(typescript@5.6.3))(zod@3.24.1)
|
||||
'@vueuse/core':
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.0(typescript@5.6.3)
|
||||
@ -39,12 +42,18 @@ importers:
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7
|
||||
vee-validate:
|
||||
specifier: ^4.15.0
|
||||
version: 4.15.0(vue@3.5.13(typescript@5.6.3))
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.13(typescript@5.6.3)
|
||||
vue-router:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0(vue@3.5.13(typescript@5.6.3))
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
version: 3.24.1
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^3.12.0
|
||||
@ -1194,6 +1203,11 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@vee-validate/zod@4.15.0':
|
||||
resolution: {integrity: sha512-MpvIKiyg9X5yD8bJW0no2AU7wtR2T5mrvD9tuPRiie951sU2n6QKgMV38qKKOiqFBCxsMSjIuLLLV3V5kVE4nQ==}
|
||||
peerDependencies:
|
||||
zod: ^3.24.0
|
||||
|
||||
'@vitejs/plugin-vue@5.2.1':
|
||||
resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@ -1460,6 +1474,9 @@ packages:
|
||||
'@vue/devtools-api@6.6.4':
|
||||
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
|
||||
|
||||
'@vue/devtools-api@7.7.0':
|
||||
resolution: {integrity: sha512-bHEv6kT85BHtyGgDhE07bAUMAy7zpv6nnR004nSTd0wWMrAOtcrYoXO5iyr20Hkf5jR8obQOfS3byW+I3l2CCA==}
|
||||
|
||||
'@vue/devtools-core@7.7.0':
|
||||
resolution: {integrity: sha512-tSO3pghV5RZGSonZ87S2fOGru3X93epmar5IjZOWjHxH6XSwnK5UbR2aW5puZV+LgLoVYrcNou3krSo5k1F31g==}
|
||||
peerDependencies:
|
||||
@ -3635,6 +3652,10 @@ packages:
|
||||
resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
type-fest@4.31.0:
|
||||
resolution: {integrity: sha512-yCxltHW07Nkhv/1F6wWBr8kz+5BGMfP+RbRSYFnegVb0qV/UMT0G0ElBloPVerqn4M2ZV80Ir1FtCcYv1cT6vQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
typescript@5.6.3:
|
||||
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@ -3785,6 +3806,11 @@ packages:
|
||||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
vee-validate@4.15.0:
|
||||
resolution: {integrity: sha512-PGJh1QCFwCBjbHu5aN6vB8macYVWrajbDvgo1Y/8fz9n/RVIkLmZCJDpUgu7+mUmCOPMxeyq7vXUOhbwAqdXcA==}
|
||||
peerDependencies:
|
||||
vue: ^3.4.26
|
||||
|
||||
vite-hot-client@0.2.4:
|
||||
resolution: {integrity: sha512-a1nzURqO7DDmnXqabFOliz908FRmIppkBKsJthS8rbe8hBEXwEwe4C3Pp33Z1JoFCYfVL4kTOMLKk0ZZxREIeA==}
|
||||
peerDependencies:
|
||||
@ -5140,6 +5166,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vee-validate/zod@4.15.0(vue@3.5.13(typescript@5.6.3))(zod@3.24.1)':
|
||||
dependencies:
|
||||
type-fest: 4.31.0
|
||||
vee-validate: 4.15.0(vue@3.5.13(typescript@5.6.3))
|
||||
zod: 3.24.1
|
||||
transitivePeerDependencies:
|
||||
- vue
|
||||
|
||||
'@vitejs/plugin-vue@5.2.1(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.0)(stylus@0.57.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.13(typescript@5.6.3))':
|
||||
dependencies:
|
||||
vite: 6.0.3(@types/node@22.10.2)(jiti@2.4.0)(stylus@0.57.0)(tsx@4.19.2)(yaml@2.6.1)
|
||||
@ -5563,6 +5597,10 @@ snapshots:
|
||||
|
||||
'@vue/devtools-api@6.6.4': {}
|
||||
|
||||
'@vue/devtools-api@7.7.0':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.0
|
||||
|
||||
'@vue/devtools-core@7.7.0(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.0)(stylus@0.57.0)(tsx@4.19.2)(yaml@2.6.1))(vue@3.5.13(typescript@5.6.3))':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.0
|
||||
@ -8085,6 +8123,8 @@ snapshots:
|
||||
|
||||
type-fest@0.8.1: {}
|
||||
|
||||
type-fest@4.31.0: {}
|
||||
|
||||
typescript@5.6.3: {}
|
||||
|
||||
ufo@1.5.4: {}
|
||||
@ -8328,6 +8368,12 @@ snapshots:
|
||||
spdx-correct: 3.2.0
|
||||
spdx-expression-parse: 3.0.1
|
||||
|
||||
vee-validate@4.15.0(vue@3.5.13(typescript@5.6.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.0
|
||||
type-fest: 4.31.0
|
||||
vue: 3.5.13(typescript@5.6.3)
|
||||
|
||||
vite-hot-client@0.2.4(vite@6.0.3(@types/node@22.10.2)(jiti@2.4.0)(stylus@0.57.0)(tsx@4.19.2)(yaml@2.6.1)):
|
||||
dependencies:
|
||||
vite: 6.0.3(@types/node@22.10.2)(jiti@2.4.0)(stylus@0.57.0)(tsx@4.19.2)(yaml@2.6.1)
|
||||
|
||||
@ -184,9 +184,7 @@ const data = {
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div class="jusitfy-center w-full flex flex-1 flex-col items-center gap-4 p-4 pt-0">
|
||||
<RouterView />
|
||||
</div>
|
||||
<RouterView />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
|
||||
@ -1,24 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
imageUrl: string
|
||||
}>()
|
||||
|
||||
function download() {
|
||||
const a = document.createElement('a')
|
||||
a.href = props.imageUrl
|
||||
a.download = 'image.png'
|
||||
a.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<img id="image_" class="w-400" :src="imageUrl">
|
||||
<div my-2 w-full>
|
||||
<Button class="w-2/3" @click="download">
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
20
src/components/ImageContainer.vue
Normal file
20
src/components/ImageContainer.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
imageUrl: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full min-h-[50vh] w-full flex flex-col rounded-xl bg-muted/50 p-4 lg:col-span-2">
|
||||
<Badge variant="outline" class="absolute right-3 top-3">
|
||||
Output
|
||||
</Badge>
|
||||
<div class="flex-1" />
|
||||
<Image :image-url="imageUrl" />
|
||||
|
||||
<Button type="submit" size="sm" class="ml-auto gap-1.5">
|
||||
下载图片
|
||||
<CornerDownLeft class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
43
src/components/ParamsCard.vue
Normal file
43
src/components/ParamsCard.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ZodObject } from 'zod'
|
||||
|
||||
defineProps<{
|
||||
schema: ZodObject<any, any, any> | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
submit: [value: {
|
||||
[x: string]: any
|
||||
}]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer>
|
||||
<DrawerTrigger as-child class="min-w-[20rem]">
|
||||
<Button variant="ghost" size="icon" class="md:hidden">
|
||||
<Settings class="size-4" />
|
||||
<span class="sr-only">Settings</span>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent class="max-h-[80vh]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle />
|
||||
</DrawerHeader>
|
||||
<form class="grid min-w-[20rem] w-full items-start gap-6 overflow-auto p-4 pt-0">
|
||||
<fieldset class="grid gap-6 border rounded-lg p-4">
|
||||
<legend class="px-1 text-sm font-medium -ml-1">
|
||||
Settings
|
||||
</legend>
|
||||
<div class="grid gap-3">
|
||||
<AutoForm v-if="schema" :schema="schema" @submit="(e) => $emit('submit', e)">
|
||||
<Button type="submit" class="w-full">
|
||||
绘制
|
||||
</Button>
|
||||
</AutoForm>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</template>
|
||||
19
src/components/ui/accordion/Accordion.vue
Normal file
19
src/components/ui/accordion/Accordion.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AccordionRoot,
|
||||
type AccordionRootEmits,
|
||||
type AccordionRootProps,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
|
||||
const props = defineProps<AccordionRootProps>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
24
src/components/ui/accordion/AccordionContent.vue
Normal file
24
src/components/ui/accordion/AccordionContent.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { AccordionContent, type AccordionContentProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
v-bind="delegatedProps"
|
||||
class="overflow-hidden text-sm transition-all data-[state=open]:animate-accordion-down data-[state=closed]:animate-accordion-up"
|
||||
>
|
||||
<div :class="cn('pb-4 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
24
src/components/ui/accordion/AccordionItem.vue
Normal file
24
src/components/ui/accordion/AccordionItem.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
39
src/components/ui/accordion/AccordionTrigger.vue
Normal file
39
src/components/ui/accordion/AccordionTrigger.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import {
|
||||
AccordionHeader,
|
||||
AccordionTrigger,
|
||||
type AccordionTriggerProps,
|
||||
} from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<ChevronDown
|
||||
class="h-4 w-4 shrink-0 transition-transform duration-200"
|
||||
/>
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
4
src/components/ui/accordion/index.ts
Normal file
4
src/components/ui/accordion/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as Accordion } from './Accordion.vue'
|
||||
export { default as AccordionContent } from './AccordionContent.vue'
|
||||
export { default as AccordionItem } from './AccordionItem.vue'
|
||||
export { default as AccordionTrigger } from './AccordionTrigger.vue'
|
||||
105
src/components/ui/auto-form/AutoForm.vue
Normal file
105
src/components/ui/auto-form/AutoForm.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<script setup lang="ts" generic="T extends ZodObjectOrWrapped">
|
||||
import type { FormContext, GenericObject } from 'vee-validate'
|
||||
import type { z, ZodAny } from 'zod'
|
||||
import type { Config, ConfigItem, Dependency, Shape } from './interface'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { Form } from '~/components/ui/form'
|
||||
import AutoFormField from './AutoFormField.vue'
|
||||
import { provideDependencies } from './dependencies'
|
||||
import { getBaseSchema, getBaseType, getDefaultValueInZodStack, getObjectFormSchema, type ZodObjectOrWrapped } from './utils'
|
||||
|
||||
const props = defineProps<{
|
||||
schema: T
|
||||
form?: FormContext<GenericObject>
|
||||
fieldConfig?: Config<z.infer<T>>
|
||||
dependencies?: Dependency<z.infer<T>>[]
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
submit: [event: z.infer<T>]
|
||||
}>()
|
||||
|
||||
const { dependencies } = toRefs(props)
|
||||
provideDependencies(dependencies)
|
||||
|
||||
const shapes = computed(() => {
|
||||
// @ts-expect-error ignore {} not assignable to object
|
||||
const val: { [key in keyof T]: Shape } = {}
|
||||
const baseSchema = getObjectFormSchema(props.schema)
|
||||
const shape = baseSchema.shape
|
||||
Object.keys(shape).forEach((name) => {
|
||||
const item = shape[name] as ZodAny
|
||||
const baseItem = getBaseSchema(item) as ZodAny
|
||||
let options = (baseItem && 'values' in baseItem._def) ? baseItem._def.values as string[] : undefined
|
||||
if (!Array.isArray(options) && typeof options === 'object')
|
||||
options = Object.values(options)
|
||||
|
||||
val[name as keyof T] = {
|
||||
type: getBaseType(item),
|
||||
default: getDefaultValueInZodStack(item),
|
||||
options,
|
||||
required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),
|
||||
schema: baseItem,
|
||||
}
|
||||
})
|
||||
return val
|
||||
})
|
||||
|
||||
const fields = computed(() => {
|
||||
// @ts-expect-error ignore {} not assignable to object
|
||||
const val: { [key in keyof z.infer<T>]: { shape: Shape, fieldName: string, config: ConfigItem } } = {}
|
||||
for (const key in shapes.value) {
|
||||
const shape = shapes.value[key]
|
||||
val[key as keyof z.infer<T>] = {
|
||||
shape,
|
||||
config: props.fieldConfig?.[key] as ConfigItem,
|
||||
fieldName: key,
|
||||
}
|
||||
}
|
||||
return val
|
||||
})
|
||||
|
||||
const formComponent = computed(() => props.form ? 'form' : Form)
|
||||
const formComponentProps = computed(() => {
|
||||
if (props.form) {
|
||||
return {
|
||||
onSubmit: props.form.handleSubmit(val => emits('submit', val)),
|
||||
}
|
||||
}
|
||||
else {
|
||||
const formSchema = toTypedSchema(props.schema)
|
||||
return {
|
||||
keepValues: true,
|
||||
validationSchema: formSchema,
|
||||
onSubmit: (val: GenericObject) => emits('submit', val),
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="formComponent"
|
||||
v-bind="formComponentProps"
|
||||
>
|
||||
<slot name="customAutoForm" :fields="fields">
|
||||
<template v-for="(shape, key) of shapes" :key="key">
|
||||
<slot
|
||||
:shape="shape"
|
||||
:name="key.toString() as keyof z.infer<T>"
|
||||
:field-name="key.toString()"
|
||||
:config="fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem"
|
||||
>
|
||||
<AutoFormField
|
||||
:config="fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem"
|
||||
:field-name="key.toString()"
|
||||
:shape="shape"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<slot :shapes="shapes" />
|
||||
</component>
|
||||
</template>
|
||||
45
src/components/ui/auto-form/AutoFormField.vue
Normal file
45
src/components/ui/auto-form/AutoFormField.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup lang="ts" generic="U extends ZodAny">
|
||||
import type { ZodAny } from 'zod'
|
||||
import type { Config, ConfigItem, Shape } from './interface'
|
||||
import { computed } from 'vue'
|
||||
import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from './constant'
|
||||
import useDependencies from './dependencies'
|
||||
|
||||
const props = defineProps<{
|
||||
fieldName: string
|
||||
shape: Shape
|
||||
config?: ConfigItem | Config<U>
|
||||
}>()
|
||||
|
||||
function isValidConfig(config: any): config is ConfigItem {
|
||||
return !!config?.component
|
||||
}
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
if (['ZodObject', 'ZodArray'].includes(props.shape?.type))
|
||||
return { schema: props.shape?.schema }
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { isDisabled, isHidden, isRequired, overrideOptions } = useDependencies(props.fieldName)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="isValidConfig(config)
|
||||
? typeof config.component === 'string'
|
||||
? INPUT_COMPONENTS[config.component!]
|
||||
: config.component
|
||||
: INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] "
|
||||
v-if="!isHidden"
|
||||
:field-name="fieldName"
|
||||
:label="shape.schema?.description"
|
||||
:required="isRequired || shape.required"
|
||||
:options="overrideOptions || shape.options"
|
||||
:disabled="isDisabled"
|
||||
:config="config"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
110
src/components/ui/auto-form/AutoFormFieldArray.vue
Normal file
110
src/components/ui/auto-form/AutoFormFieldArray.vue
Normal file
@ -0,0 +1,110 @@
|
||||
<script setup lang="ts" generic="T extends z.ZodAny">
|
||||
import type { Config, ConfigItem } from './interface'
|
||||
import { PlusIcon, TrashIcon } from 'lucide-vue-next'
|
||||
import { FieldArray, FieldContextKey, useField } from 'vee-validate'
|
||||
import { computed, provide } from 'vue'
|
||||
import * as z from 'zod'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '~/components/ui/accordion'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { FormItem, FormMessage } from '~/components/ui/form'
|
||||
import { Separator } from '~/components/ui/separator'
|
||||
import AutoFormField from './AutoFormField.vue'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import { beautifyObjectName, getBaseType } from './utils'
|
||||
|
||||
const props = defineProps<{
|
||||
fieldName: string
|
||||
required?: boolean
|
||||
config?: Config<T>
|
||||
schema?: z.ZodArray<T>
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
function isZodArray(
|
||||
item: z.ZodArray<any> | z.ZodDefault<any>,
|
||||
): item is z.ZodArray<any> {
|
||||
return item instanceof z.ZodArray
|
||||
}
|
||||
|
||||
function isZodDefault(
|
||||
item: z.ZodArray<any> | z.ZodDefault<any>,
|
||||
): item is z.ZodDefault<any> {
|
||||
return item instanceof z.ZodDefault
|
||||
}
|
||||
|
||||
const itemShape = computed(() => {
|
||||
if (!props.schema)
|
||||
return
|
||||
|
||||
const schema: z.ZodAny = isZodArray(props.schema)
|
||||
? props.schema._def.type
|
||||
: isZodDefault(props.schema)
|
||||
// @ts-expect-error missing schema
|
||||
? props.schema._def.innerType._def.type
|
||||
: null
|
||||
|
||||
return {
|
||||
type: getBaseType(schema),
|
||||
schema,
|
||||
}
|
||||
})
|
||||
|
||||
const fieldContext = useField(props.fieldName)
|
||||
// @ts-expect-error ignore missing `id`
|
||||
provide(FieldContextKey, fieldContext)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FieldArray v-slot="{ fields, remove, push }" as="section" :name="fieldName">
|
||||
<slot v-bind="props">
|
||||
<Accordion type="multiple" class="w-full" :disabled="disabled" as-child collapsible>
|
||||
<FormItem>
|
||||
<AccordionItem :value="fieldName" class="border-none">
|
||||
<AccordionTrigger>
|
||||
<AutoFormLabel class="text-base" :required="required">
|
||||
{{ schema?.description || beautifyObjectName(fieldName) }}
|
||||
</AutoFormLabel>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent>
|
||||
<template v-for="(field, index) of fields" :key="field.key">
|
||||
<div class="mb-4 p-1">
|
||||
<AutoFormField
|
||||
:field-name="`${fieldName}[${index}]`"
|
||||
:label="fieldName"
|
||||
:shape="itemShape!"
|
||||
:config="config as ConfigItem"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end !my-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
@click="remove(index)"
|
||||
>
|
||||
<TrashIcon :size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator v-if="!field.isLast" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="mt-4 flex items-center"
|
||||
@click="push(null)"
|
||||
>
|
||||
<PlusIcon class="mr-2" :size="16" />
|
||||
Add
|
||||
</Button>
|
||||
</AccordionContent>
|
||||
|
||||
<FormMessage />
|
||||
</AccordionItem>
|
||||
</FormItem>
|
||||
</Accordion>
|
||||
</slot>
|
||||
</FieldArray>
|
||||
</template>
|
||||
41
src/components/ui/auto-form/AutoFormFieldBoolean.vue
Normal file
41
src/components/ui/auto-form/AutoFormFieldBoolean.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldProps } from './interface'
|
||||
import { computed } from 'vue'
|
||||
import { Checkbox } from '~/components/ui/checkbox'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '~/components/ui/form'
|
||||
import { Switch } from '~/components/ui/switch'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import { beautifyObjectName } from './utils'
|
||||
|
||||
const props = defineProps<FieldProps>()
|
||||
|
||||
const booleanComponent = computed(() => props.config?.component === 'switch' ? Switch : Checkbox)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="slotProps" :name="fieldName">
|
||||
<FormItem>
|
||||
<div class="mb-3 flex items-center gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<slot v-bind="slotProps">
|
||||
<component
|
||||
:is="booleanComponent"
|
||||
v-bind="{ ...slotProps.componentField }"
|
||||
:disabled="disabled"
|
||||
:checked="slotProps.componentField.modelValue"
|
||||
@update:checked="slotProps.componentField['onUpdate:modelValue']"
|
||||
/>
|
||||
</slot>
|
||||
</FormControl>
|
||||
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||
</AutoFormLabel>
|
||||
</div>
|
||||
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
57
src/components/ui/auto-form/AutoFormFieldDate.vue
Normal file
57
src/components/ui/auto-form/AutoFormFieldDate.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldProps } from './interface'
|
||||
import { DateFormatter, getLocalTimeZone } from '@internationalized/date'
|
||||
import { CalendarIcon } from 'lucide-vue-next'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Calendar } from '~/components/ui/calendar'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '~/components/ui/form'
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'
|
||||
import { cn } from '~/lib/utils'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import { beautifyObjectName } from './utils'
|
||||
|
||||
defineProps<FieldProps>()
|
||||
|
||||
const df = new DateFormatter('en-US', {
|
||||
dateStyle: 'long',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="slotProps" :name="fieldName">
|
||||
<FormItem>
|
||||
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<slot v-bind="slotProps">
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child :disabled="disabled">
|
||||
<Button
|
||||
variant="outline"
|
||||
:class="cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!slotProps.componentField.modelValue && 'text-muted-foreground',
|
||||
)"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" :size="16" />
|
||||
{{ slotProps.componentField.modelValue ? df.format(slotProps.componentField.modelValue.toDate(getLocalTimeZone())) : "Pick a date" }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar initial-focus v-bind="slotProps.componentField" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</slot>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
49
src/components/ui/auto-form/AutoFormFieldEnum.vue
Normal file
49
src/components/ui/auto-form/AutoFormFieldEnum.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldProps } from './interface'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '~/components/ui/form'
|
||||
import { Label } from '~/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '~/components/ui/radio-group'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import { beautifyObjectName } from './utils'
|
||||
|
||||
defineProps<FieldProps & {
|
||||
options?: string[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="slotProps" :name="fieldName">
|
||||
<FormItem>
|
||||
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<slot v-bind="slotProps">
|
||||
<RadioGroup v-if="config?.component === 'radio'" :disabled="disabled" orientation="vertical" v-bind="{ ...slotProps.componentField }">
|
||||
<div v-for="(option, index) in options" :key="option" class="mb-2 flex items-center gap-3 space-y-0">
|
||||
<RadioGroupItem :id="`${option}-${index}`" :value="option" />
|
||||
<Label :for="`${option}-${index}`">{{ beautifyObjectName(option) }}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<Select v-else :disabled="disabled" v-bind="{ ...slotProps.componentField }">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="config?.inputProps?.placeholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in options" :key="option" :value="option">
|
||||
{{ beautifyObjectName(option) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</slot>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
74
src/components/ui/auto-form/AutoFormFieldFile.vue
Normal file
74
src/components/ui/auto-form/AutoFormFieldFile.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldProps } from './interface'
|
||||
import { TrashIcon } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '~/components/ui/form'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import { beautifyObjectName } from './utils'
|
||||
|
||||
defineProps<FieldProps>()
|
||||
|
||||
const inputFile = ref<File>()
|
||||
async function parseFileAsString(file: File | undefined): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result as string)
|
||||
}
|
||||
reader.onerror = (err) => {
|
||||
reject(err)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="slotProps" :name="fieldName">
|
||||
<FormItem v-bind="$attrs">
|
||||
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<slot v-bind="slotProps">
|
||||
<Input
|
||||
v-if="!inputFile"
|
||||
type="file"
|
||||
v-bind="{ ...config?.inputProps }"
|
||||
:disabled="disabled"
|
||||
@change="async (ev: InputEvent) => {
|
||||
const file = (ev.target as HTMLInputElement).files?.[0]
|
||||
inputFile = file
|
||||
const parsed = await parseFileAsString(file)
|
||||
slotProps.componentField.onInput(parsed)
|
||||
}"
|
||||
/>
|
||||
<div v-else class="h-10 w-full flex items-center justify-between border border-input rounded-md bg-transparent py-1 pl-3 pr-1 text-sm shadow-sm transition-colors">
|
||||
<p>{{ inputFile?.name }}</p>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-[26px] w-[26px]"
|
||||
aria-label="Remove file"
|
||||
type="button"
|
||||
@click="() => {
|
||||
inputFile = undefined
|
||||
slotProps.componentField.onInput(undefined)
|
||||
}"
|
||||
>
|
||||
<TrashIcon :size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
</slot>
|
||||
</FormControl>
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
36
src/components/ui/auto-form/AutoFormFieldInput.vue
Normal file
36
src/components/ui/auto-form/AutoFormFieldInput.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldProps } from './interface'
|
||||
import { computed } from 'vue'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '~/components/ui/form'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import { Textarea } from '~/components/ui/textarea'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import { beautifyObjectName } from './utils'
|
||||
|
||||
const props = defineProps<FieldProps>()
|
||||
const inputComponent = computed(() => props.config?.component === 'textarea' ? Textarea : Input)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="slotProps" :name="fieldName">
|
||||
<FormItem v-bind="$attrs">
|
||||
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<slot v-bind="slotProps">
|
||||
<component
|
||||
:is="inputComponent"
|
||||
type="text"
|
||||
v-bind="{ ...slotProps.componentField, ...config?.inputProps }"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</slot>
|
||||
</FormControl>
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
32
src/components/ui/auto-form/AutoFormFieldNumber.vue
Normal file
32
src/components/ui/auto-form/AutoFormFieldNumber.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { FieldProps } from './interface'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '~/components/ui/form'
|
||||
import { Input } from '~/components/ui/input'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import { beautifyObjectName } from './utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
defineProps<FieldProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="slotProps" :name="fieldName">
|
||||
<FormItem>
|
||||
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<slot v-bind="slotProps">
|
||||
<Input type="number" v-bind="{ ...slotProps.componentField, ...config?.inputProps }" :disabled="disabled" />
|
||||
</slot>
|
||||
</FormControl>
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
78
src/components/ui/auto-form/AutoFormFieldObject.vue
Normal file
78
src/components/ui/auto-form/AutoFormFieldObject.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<script setup lang="ts" generic="T extends ZodRawShape">
|
||||
import type { ZodAny, ZodObject, ZodRawShape } from 'zod'
|
||||
import type { Config, ConfigItem, Shape } from './interface'
|
||||
import { FieldContextKey, useField } from 'vee-validate'
|
||||
import { computed, provide } from 'vue'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '~/components/ui/accordion'
|
||||
import { FormItem } from '~/components/ui/form'
|
||||
import AutoFormField from './AutoFormField.vue'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import { beautifyObjectName, getBaseSchema, getBaseType, getDefaultValueInZodStack } from './utils'
|
||||
|
||||
const props = defineProps<{
|
||||
fieldName: string
|
||||
required?: boolean
|
||||
config?: Config<T>
|
||||
schema?: ZodObject<T>
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const shapes = computed(() => {
|
||||
// @ts-expect-error ignore {} not assignable to object
|
||||
const val: { [key in keyof T]: Shape } = {}
|
||||
|
||||
if (!props.schema)
|
||||
return
|
||||
const shape = getBaseSchema(props.schema)?.shape
|
||||
if (!shape)
|
||||
return
|
||||
Object.keys(shape).forEach((name) => {
|
||||
const item = shape[name] as ZodAny
|
||||
const baseItem = getBaseSchema(item) as ZodAny
|
||||
let options = (baseItem && 'values' in baseItem._def) ? baseItem._def.values as string[] : undefined
|
||||
if (!Array.isArray(options) && typeof options === 'object')
|
||||
options = Object.values(options)
|
||||
|
||||
val[name as keyof T] = {
|
||||
type: getBaseType(item),
|
||||
default: getDefaultValueInZodStack(item),
|
||||
options,
|
||||
required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),
|
||||
schema: item,
|
||||
}
|
||||
})
|
||||
return val
|
||||
})
|
||||
|
||||
const fieldContext = useField(props.fieldName)
|
||||
// @ts-expect-error ignore missing `id`
|
||||
provide(FieldContextKey, fieldContext)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<slot v-bind="props">
|
||||
<Accordion type="single" as-child class="w-full" collapsible :disabled="disabled">
|
||||
<FormItem>
|
||||
<AccordionItem :value="fieldName" class="border-none">
|
||||
<AccordionTrigger>
|
||||
<AutoFormLabel class="text-base" :required="required">
|
||||
{{ schema?.description || beautifyObjectName(fieldName) }}
|
||||
</AutoFormLabel>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="p-1 space-y-5">
|
||||
<template v-for="(shape, key) in shapes" :key="key">
|
||||
<AutoFormField
|
||||
:config="config?.[key as keyof typeof config] as ConfigItem"
|
||||
:field-name="`${fieldName}.${key.toString()}`"
|
||||
:label="key.toString()"
|
||||
:shape="shape"
|
||||
/>
|
||||
</template>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</FormItem>
|
||||
</Accordion>
|
||||
</slot>
|
||||
</section>
|
||||
</template>
|
||||
14
src/components/ui/auto-form/AutoFormLabel.vue
Normal file
14
src/components/ui/auto-form/AutoFormLabel.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { FormLabel } from '~/components/ui/form'
|
||||
|
||||
defineProps<{
|
||||
required?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormLabel>
|
||||
<slot />
|
||||
<span v-if="required" class="text-destructive"> *</span>
|
||||
</FormLabel>
|
||||
</template>
|
||||
40
src/components/ui/auto-form/constant.ts
Normal file
40
src/components/ui/auto-form/constant.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { InputComponents } from './interface'
|
||||
import AutoFormFieldArray from './AutoFormFieldArray.vue'
|
||||
import AutoFormFieldBoolean from './AutoFormFieldBoolean.vue'
|
||||
import AutoFormFieldDate from './AutoFormFieldDate.vue'
|
||||
import AutoFormFieldEnum from './AutoFormFieldEnum.vue'
|
||||
import AutoFormFieldFile from './AutoFormFieldFile.vue'
|
||||
import AutoFormFieldInput from './AutoFormFieldInput.vue'
|
||||
import AutoFormFieldNumber from './AutoFormFieldNumber.vue'
|
||||
import AutoFormFieldObject from './AutoFormFieldObject.vue'
|
||||
|
||||
export const INPUT_COMPONENTS: InputComponents = {
|
||||
date: AutoFormFieldDate,
|
||||
select: AutoFormFieldEnum,
|
||||
radio: AutoFormFieldEnum,
|
||||
checkbox: AutoFormFieldBoolean,
|
||||
switch: AutoFormFieldBoolean,
|
||||
textarea: AutoFormFieldInput,
|
||||
number: AutoFormFieldNumber,
|
||||
string: AutoFormFieldInput,
|
||||
file: AutoFormFieldFile,
|
||||
array: AutoFormFieldArray,
|
||||
object: AutoFormFieldObject,
|
||||
}
|
||||
|
||||
/**
|
||||
* Define handlers for specific Zod types.
|
||||
* You can expand this object to support more types.
|
||||
*/
|
||||
export const DEFAULT_ZOD_HANDLERS: {
|
||||
[key: string]: keyof typeof INPUT_COMPONENTS
|
||||
} = {
|
||||
ZodString: 'string',
|
||||
ZodBoolean: 'checkbox',
|
||||
ZodDate: 'date',
|
||||
ZodEnum: 'select',
|
||||
ZodNativeEnum: 'select',
|
||||
ZodNumber: 'number',
|
||||
ZodArray: 'array',
|
||||
ZodObject: 'object',
|
||||
}
|
||||
92
src/components/ui/auto-form/dependencies.ts
Normal file
92
src/components/ui/auto-form/dependencies.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type * as z from 'zod'
|
||||
import { createContext } from 'radix-vue'
|
||||
import { useFieldValue, useFormValues } from 'vee-validate'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { type Dependency, DependencyType, type EnumValues } from './interface'
|
||||
import { getFromPath, getIndexIfArray } from './utils'
|
||||
|
||||
export const [injectDependencies, provideDependencies] = createContext<Ref<Dependency<z.infer<z.ZodObject<any>>>[] | undefined>>('AutoFormDependencies')
|
||||
|
||||
export default function useDependencies(
|
||||
fieldName: string,
|
||||
) {
|
||||
const form = useFormValues()
|
||||
// parsed test[0].age => test.age
|
||||
const currentFieldName = fieldName.replace(/\[\d+\]/g, '')
|
||||
const currentFieldValue = useFieldValue<any>(fieldName)
|
||||
|
||||
if (!form)
|
||||
throw new Error('useDependencies should be used within <AutoForm>')
|
||||
|
||||
const dependencies = injectDependencies()
|
||||
const isDisabled = ref(false)
|
||||
const isHidden = ref(false)
|
||||
const isRequired = ref(false)
|
||||
const overrideOptions = ref<EnumValues | undefined>()
|
||||
|
||||
const currentFieldDependencies = computed(() => dependencies.value?.filter(
|
||||
dependency => dependency.targetField === currentFieldName,
|
||||
))
|
||||
|
||||
function getSourceValue(dep: Dependency<any>) {
|
||||
const source = dep.sourceField as string
|
||||
const index = getIndexIfArray(fieldName) ?? -1
|
||||
const [sourceLast, ...sourceInitial] = source.split('.').toReversed()
|
||||
const [_targetLast, ...targetInitial] = (dep.targetField as string).split('.').toReversed()
|
||||
|
||||
if (index >= 0 && sourceInitial.join(',') === targetInitial.join(',')) {
|
||||
const [_currentLast, ...currentInitial] = fieldName.split('.').toReversed()
|
||||
return getFromPath(form.value, currentInitial.join('.') + sourceLast)
|
||||
}
|
||||
|
||||
return getFromPath(form.value, source)
|
||||
}
|
||||
|
||||
const sourceFieldValues = computed(() => currentFieldDependencies.value?.map(dep => getSourceValue(dep)))
|
||||
|
||||
const resetConditionState = () => {
|
||||
isDisabled.value = false
|
||||
isHidden.value = false
|
||||
isRequired.value = false
|
||||
overrideOptions.value = undefined
|
||||
}
|
||||
|
||||
watch([sourceFieldValues, dependencies], () => {
|
||||
resetConditionState()
|
||||
currentFieldDependencies.value?.forEach((dep) => {
|
||||
const sourceValue = getSourceValue(dep)
|
||||
const conditionMet = dep.when(sourceValue, currentFieldValue.value)
|
||||
|
||||
switch (dep.type) {
|
||||
case DependencyType.DISABLES:
|
||||
if (conditionMet)
|
||||
isDisabled.value = true
|
||||
|
||||
break
|
||||
case DependencyType.REQUIRES:
|
||||
if (conditionMet)
|
||||
isRequired.value = true
|
||||
|
||||
break
|
||||
case DependencyType.HIDES:
|
||||
if (conditionMet)
|
||||
isHidden.value = true
|
||||
|
||||
break
|
||||
case DependencyType.SETS_OPTIONS:
|
||||
if (conditionMet)
|
||||
overrideOptions.value = dep.options
|
||||
|
||||
break
|
||||
}
|
||||
})
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
return {
|
||||
isDisabled,
|
||||
isHidden,
|
||||
isRequired,
|
||||
overrideOptions,
|
||||
}
|
||||
}
|
||||
15
src/components/ui/auto-form/index.ts
Normal file
15
src/components/ui/auto-form/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export { default as AutoForm } from './AutoForm.vue'
|
||||
export { default as AutoFormField } from './AutoFormField.vue'
|
||||
|
||||
export { default as AutoFormFieldArray } from './AutoFormFieldArray.vue'
|
||||
export { default as AutoFormFieldBoolean } from './AutoFormFieldBoolean.vue'
|
||||
export { default as AutoFormFieldDate } from './AutoFormFieldDate.vue'
|
||||
|
||||
export { default as AutoFormFieldEnum } from './AutoFormFieldEnum.vue'
|
||||
export { default as AutoFormFieldFile } from './AutoFormFieldFile.vue'
|
||||
export { default as AutoFormFieldInput } from './AutoFormFieldInput.vue'
|
||||
export { default as AutoFormFieldNumber } from './AutoFormFieldNumber.vue'
|
||||
export { default as AutoFormFieldObject } from './AutoFormFieldObject.vue'
|
||||
export { default as AutoFormLabel } from './AutoFormLabel.vue'
|
||||
export type { Config, ConfigItem, FieldProps } from './interface'
|
||||
export { getBaseSchema, getBaseType, getObjectFormSchema } from './utils'
|
||||
95
src/components/ui/auto-form/interface.ts
Normal file
95
src/components/ui/auto-form/interface.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { Component, InputHTMLAttributes } from 'vue'
|
||||
import type { z, ZodAny } from 'zod'
|
||||
import type { INPUT_COMPONENTS } from './constant'
|
||||
|
||||
export interface FieldProps {
|
||||
fieldName: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
config?: ConfigItem
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface Shape {
|
||||
type: string
|
||||
default?: any
|
||||
required?: boolean
|
||||
options?: string[]
|
||||
schema?: ZodAny
|
||||
}
|
||||
|
||||
export interface InputComponents {
|
||||
date: Component
|
||||
select: Component
|
||||
radio: Component
|
||||
checkbox: Component
|
||||
switch: Component
|
||||
textarea: Component
|
||||
number: Component
|
||||
string: Component
|
||||
file: Component
|
||||
array: Component
|
||||
object: Component
|
||||
}
|
||||
|
||||
export interface ConfigItem {
|
||||
/** Value for the `FormLabel` */
|
||||
label?: string
|
||||
/** Value for the `FormDescription` */
|
||||
description?: string
|
||||
/** Pick which component to be rendered. */
|
||||
component?: keyof typeof INPUT_COMPONENTS | Component
|
||||
/** Hide `FormLabel`. */
|
||||
hideLabel?: boolean
|
||||
inputProps?: InputHTMLAttributes
|
||||
}
|
||||
|
||||
// Define a type to unwrap an array
|
||||
type UnwrapArray<T> = T extends (infer U)[] ? U : never
|
||||
|
||||
export type Config<SchemaType extends object> = {
|
||||
// If SchemaType.key is an object, create a nested Config, otherwise ConfigItem
|
||||
[Key in keyof SchemaType]?:
|
||||
SchemaType[Key] extends any[]
|
||||
? UnwrapArray<Config<SchemaType[Key]>>
|
||||
: SchemaType[Key] extends object
|
||||
? Config<SchemaType[Key]>
|
||||
: ConfigItem;
|
||||
}
|
||||
|
||||
export enum DependencyType {
|
||||
DISABLES,
|
||||
REQUIRES,
|
||||
HIDES,
|
||||
SETS_OPTIONS,
|
||||
}
|
||||
|
||||
interface BaseDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> {
|
||||
sourceField: keyof SchemaType
|
||||
type: DependencyType
|
||||
targetField: keyof SchemaType
|
||||
when: (sourceFieldValue: any, targetFieldValue: any) => boolean
|
||||
}
|
||||
|
||||
export type ValueDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =
|
||||
BaseDependency<SchemaType> & {
|
||||
type:
|
||||
| DependencyType.DISABLES
|
||||
| DependencyType.REQUIRES
|
||||
| DependencyType.HIDES
|
||||
}
|
||||
|
||||
export type EnumValues = readonly [string, ...string[]]
|
||||
|
||||
export type OptionsDependency<
|
||||
SchemaType extends z.infer<z.ZodObject<any, any>>,
|
||||
> = BaseDependency<SchemaType> & {
|
||||
type: DependencyType.SETS_OPTIONS
|
||||
|
||||
// Partial array of values from sourceField that will trigger the dependency
|
||||
options: EnumValues
|
||||
}
|
||||
|
||||
export type Dependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =
|
||||
| ValueDependency<SchemaType>
|
||||
| OptionsDependency<SchemaType>
|
||||
171
src/components/ui/auto-form/utils.ts
Normal file
171
src/components/ui/auto-form/utils.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import type { z } from 'zod'
|
||||
|
||||
// TODO: This should support recursive ZodEffects but TypeScript doesn't allow circular type definitions.
|
||||
export type ZodObjectOrWrapped =
|
||||
| z.ZodObject<any, any>
|
||||
| z.ZodEffects<z.ZodObject<any, any>>
|
||||
|
||||
/**
|
||||
* Beautify a camelCase string.
|
||||
* e.g. "myString" -> "My String"
|
||||
*/
|
||||
export function beautifyObjectName(string: string) {
|
||||
// Remove bracketed indices
|
||||
// if numbers only return the string
|
||||
let output = string.replace(/\[\d+\]/g, '').replace(/([A-Z])/g, ' $1')
|
||||
output = output.charAt(0).toUpperCase() + output.slice(1)
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse string and extract the index
|
||||
* @param string
|
||||
* @returns index or undefined
|
||||
*/
|
||||
export function getIndexIfArray(string: string) {
|
||||
const indexRegex = /\[(\d+)\]/
|
||||
// Match the index
|
||||
const match = string.match(indexRegex)
|
||||
// Extract the index (number)
|
||||
const index = match ? Number.parseInt(match[1]) : undefined
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lowest level Zod type.
|
||||
* This will unpack optionals, refinements, etc.
|
||||
*/
|
||||
export function getBaseSchema<
|
||||
ChildType extends z.ZodAny | z.AnyZodObject = z.ZodAny,
|
||||
>(schema: ChildType | z.ZodEffects<ChildType>): ChildType | null {
|
||||
if (!schema)
|
||||
return null
|
||||
if ('innerType' in schema._def)
|
||||
return getBaseSchema(schema._def.innerType as ChildType)
|
||||
|
||||
if ('schema' in schema._def)
|
||||
return getBaseSchema(schema._def.schema as ChildType)
|
||||
|
||||
return schema as ChildType
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type name of the lowest level Zod type.
|
||||
* This will unpack optionals, refinements, etc.
|
||||
*/
|
||||
export function getBaseType(schema: z.ZodAny) {
|
||||
const baseSchema = getBaseSchema(schema)
|
||||
return baseSchema ? baseSchema._def.typeName : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a "ZodDefault" in the Zod stack and return its value.
|
||||
*/
|
||||
export function getDefaultValueInZodStack(schema: z.ZodAny): any {
|
||||
const typedSchema = schema as unknown as z.ZodDefault<
|
||||
z.ZodNumber | z.ZodString
|
||||
>
|
||||
|
||||
if (typedSchema._def.typeName === 'ZodDefault')
|
||||
return typedSchema._def.defaultValue()
|
||||
|
||||
if ('innerType' in typedSchema._def) {
|
||||
return getDefaultValueInZodStack(
|
||||
typedSchema._def.innerType as unknown as z.ZodAny,
|
||||
)
|
||||
}
|
||||
if ('schema' in typedSchema._def) {
|
||||
return getDefaultValueInZodStack(
|
||||
(typedSchema._def as any).schema as z.ZodAny,
|
||||
)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getObjectFormSchema(
|
||||
schema: ZodObjectOrWrapped,
|
||||
): z.ZodObject<any, any> {
|
||||
if (schema?._def.typeName === 'ZodEffects') {
|
||||
const typedSchema = schema as z.ZodEffects<z.ZodObject<any, any>>
|
||||
return getObjectFormSchema(typedSchema._def.schema)
|
||||
}
|
||||
return schema as z.ZodObject<any, any>
|
||||
}
|
||||
|
||||
function isIndex(value: unknown): value is number {
|
||||
return Number(value) >= 0
|
||||
}
|
||||
/**
|
||||
* Constructs a path with dot paths for arrays to use brackets to be compatible with vee-validate path syntax
|
||||
*/
|
||||
export function normalizeFormPath(path: string): string {
|
||||
const pathArr = path.split('.')
|
||||
if (!pathArr.length)
|
||||
return ''
|
||||
|
||||
let fullPath = String(pathArr[0])
|
||||
for (let i = 1; i < pathArr.length; i++) {
|
||||
if (isIndex(pathArr[i])) {
|
||||
fullPath += `[${pathArr[i]}]`
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath += `.${pathArr[i]}`
|
||||
}
|
||||
|
||||
return fullPath
|
||||
}
|
||||
|
||||
type NestedRecord = Record<string, unknown> | { [k: string]: NestedRecord }
|
||||
/**
|
||||
* Checks if the path opted out of nested fields using `[fieldName]` syntax
|
||||
*/
|
||||
export function isNotNestedPath(path: string) {
|
||||
return /^\[.+\]$/.test(path)
|
||||
}
|
||||
function isObject(obj: unknown): obj is Record<string, unknown> {
|
||||
return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)
|
||||
}
|
||||
function isContainerValue(value: unknown): value is Record<string, unknown> {
|
||||
return isObject(value) || Array.isArray(value)
|
||||
}
|
||||
function cleanupNonNestedPath(path: string) {
|
||||
if (isNotNestedPath(path))
|
||||
return path.replace(/\[|\]/g, '')
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a nested property value from an object
|
||||
*/
|
||||
export function getFromPath<TValue = unknown>(object: NestedRecord | undefined, path: string): TValue | undefined
|
||||
export function getFromPath<TValue = unknown, TFallback = TValue>(
|
||||
object: NestedRecord | undefined,
|
||||
path: string,
|
||||
fallback?: TFallback,
|
||||
): TValue | TFallback
|
||||
export function getFromPath<TValue = unknown, TFallback = TValue>(
|
||||
object: NestedRecord | undefined,
|
||||
path: string,
|
||||
fallback?: TFallback,
|
||||
): TValue | TFallback | undefined {
|
||||
if (!object)
|
||||
return fallback
|
||||
|
||||
if (isNotNestedPath(path))
|
||||
return object[cleanupNonNestedPath(path)] as TValue | undefined
|
||||
|
||||
const resolvedValue = (path || '')
|
||||
.split(/\.|\[(\d+)\]/)
|
||||
.filter(Boolean)
|
||||
.reduce((acc, propKey) => {
|
||||
if (isContainerValue(acc) && propKey in acc)
|
||||
return acc[propKey]
|
||||
|
||||
return fallback
|
||||
}, object as unknown)
|
||||
|
||||
return resolvedValue as TValue | undefined
|
||||
}
|
||||
16
src/components/ui/badge/Badge.vue
Normal file
16
src/components/ui/badge/Badge.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
import { type BadgeVariants, badgeVariants } from '.'
|
||||
|
||||
const props = defineProps<{
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(badgeVariants({ variant }), props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
25
src/components/ui/badge/index.ts
Normal file
25
src/components/ui/badge/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
export { default as Badge } from './Badge.vue'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
60
src/components/ui/calendar/Calendar.vue
Normal file
60
src/components/ui/calendar/Calendar.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarRoot, type CalendarRootEmits, type CalendarRootProps, useForwardPropsEmits } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from '.'
|
||||
|
||||
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const emits = defineEmits<CalendarRootEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarRoot
|
||||
v-slot="{ grid, weekDays }"
|
||||
:class="cn('p-3', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<CalendarHeader>
|
||||
<CalendarPrevButton />
|
||||
<CalendarHeading />
|
||||
<CalendarNextButton />
|
||||
</CalendarHeader>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-y-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow>
|
||||
<CalendarHeadCell
|
||||
v-for="day in weekDays" :key="day"
|
||||
>
|
||||
{{ day }}
|
||||
</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||
<CalendarCell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
>
|
||||
<CalendarCellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
/>
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</div>
|
||||
</CalendarRoot>
|
||||
</template>
|
||||
24
src/components/ui/calendar/CalendarCell.vue
Normal file
24
src/components/ui/calendar/CalendarCell.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarCell, type CalendarCellProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCell
|
||||
:class="cn('relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCell>
|
||||
</template>
|
||||
38
src/components/ui/calendar/CalendarCellTrigger.vue
Normal file
38
src/components/ui/calendar/CalendarCellTrigger.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { buttonVariants } from '~/components/ui/button'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCellTrigger
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCellTrigger>
|
||||
</template>
|
||||
24
src/components/ui/calendar/CalendarGrid.vue
Normal file
24
src/components/ui/calendar/CalendarGrid.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarGrid, type CalendarGridProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGrid
|
||||
:class="cn('w-full border-collapse space-y-1', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGrid>
|
||||
</template>
|
||||
11
src/components/ui/calendar/CalendarGridBody.vue
Normal file
11
src/components/ui/calendar/CalendarGridBody.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarGridBody, type CalendarGridBodyProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<CalendarGridBodyProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridBody v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridBody>
|
||||
</template>
|
||||
11
src/components/ui/calendar/CalendarGridHead.vue
Normal file
11
src/components/ui/calendar/CalendarGridHead.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarGridHead, type CalendarGridHeadProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<CalendarGridHeadProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridHead v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridHead>
|
||||
</template>
|
||||
21
src/components/ui/calendar/CalendarGridRow.vue
Normal file
21
src/components/ui/calendar/CalendarGridRow.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarGridRow, type CalendarGridRowProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarGridRow>
|
||||
</template>
|
||||
21
src/components/ui/calendar/CalendarHeadCell.vue
Normal file
21
src/components/ui/calendar/CalendarHeadCell.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarHeadCell, type CalendarHeadCellProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeadCell :class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarHeadCell>
|
||||
</template>
|
||||
21
src/components/ui/calendar/CalendarHeader.vue
Normal file
21
src/components/ui/calendar/CalendarHeader.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarHeader, type CalendarHeaderProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarHeader>
|
||||
</template>
|
||||
27
src/components/ui/calendar/CalendarHeading.vue
Normal file
27
src/components/ui/calendar/CalendarHeading.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarHeading, type CalendarHeadingProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeading
|
||||
v-slot="{ headingValue }"
|
||||
:class="cn('text-sm font-medium', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot :heading-value>
|
||||
{{ headingValue }}
|
||||
</slot>
|
||||
</CalendarHeading>
|
||||
</template>
|
||||
32
src/components/ui/calendar/CalendarNextButton.vue
Normal file
32
src/components/ui/calendar/CalendarNextButton.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { buttonVariants } from '~/components/ui/button'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarNext
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarNext>
|
||||
</template>
|
||||
32
src/components/ui/calendar/CalendarPrevButton.vue
Normal file
32
src/components/ui/calendar/CalendarPrevButton.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { buttonVariants } from '~/components/ui/button'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarPrev
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarPrev>
|
||||
</template>
|
||||
12
src/components/ui/calendar/index.ts
Normal file
12
src/components/ui/calendar/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export { default as Calendar } from './Calendar.vue'
|
||||
export { default as CalendarCell } from './CalendarCell.vue'
|
||||
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue'
|
||||
export { default as CalendarGrid } from './CalendarGrid.vue'
|
||||
export { default as CalendarGridBody } from './CalendarGridBody.vue'
|
||||
export { default as CalendarGridHead } from './CalendarGridHead.vue'
|
||||
export { default as CalendarGridRow } from './CalendarGridRow.vue'
|
||||
export { default as CalendarHeadCell } from './CalendarHeadCell.vue'
|
||||
export { default as CalendarHeader } from './CalendarHeader.vue'
|
||||
export { default as CalendarHeading } from './CalendarHeading.vue'
|
||||
export { default as CalendarNextButton } from './CalendarNextButton.vue'
|
||||
export { default as CalendarPrevButton } from './CalendarPrevButton.vue'
|
||||
33
src/components/ui/checkbox/Checkbox.vue
Normal file
33
src/components/ui/checkbox/Checkbox.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
props.class)"
|
||||
>
|
||||
<CheckboxIndicator class="h-full w-full flex items-center justify-center text-current">
|
||||
<slot>
|
||||
<Check class="h-4 w-4" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
1
src/components/ui/checkbox/index.ts
Normal file
1
src/components/ui/checkbox/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Checkbox } from './Checkbox.vue'
|
||||
16
src/components/ui/form/FormControl.vue
Normal file
16
src/components/ui/form/FormControl.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { Slot } from 'radix-vue'
|
||||
import { useFormField } from './useFormField'
|
||||
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot
|
||||
:id="formItemId"
|
||||
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
|
||||
:aria-invalid="!!error"
|
||||
>
|
||||
<slot />
|
||||
</Slot>
|
||||
</template>
|
||||
20
src/components/ui/form/FormDescription.vue
Normal file
20
src/components/ui/form/FormDescription.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
import { useFormField } from './useFormField'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { formDescriptionId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
:id="formDescriptionId"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
19
src/components/ui/form/FormItem.vue
Normal file
19
src/components/ui/form/FormItem.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { useId } from 'radix-vue'
|
||||
import { type HTMLAttributes, provide } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const id = useId()
|
||||
provide(FORM_ITEM_INJECTION_KEY, id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('space-y-2', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
src/components/ui/form/FormLabel.vue
Normal file
23
src/components/ui/form/FormLabel.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import type { LabelProps } from 'radix-vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { Label } from '~/components/ui/label'
|
||||
import { cn } from '~/lib/utils'
|
||||
import { useFormField } from './useFormField'
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const { error, formItemId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
:class="cn(
|
||||
error && 'text-destructive',
|
||||
props.class,
|
||||
)"
|
||||
:for="formItemId"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
16
src/components/ui/form/FormMessage.vue
Normal file
16
src/components/ui/form/FormMessage.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { ErrorMessage } from 'vee-validate'
|
||||
import { toValue } from 'vue'
|
||||
import { useFormField } from './useFormField'
|
||||
|
||||
const { name, formMessageId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ErrorMessage
|
||||
:id="formMessageId"
|
||||
as="p"
|
||||
:name="toValue(name)"
|
||||
class="text-sm text-destructive font-medium"
|
||||
/>
|
||||
</template>
|
||||
7
src/components/ui/form/index.ts
Normal file
7
src/components/ui/form/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { default as FormControl } from './FormControl.vue'
|
||||
export { default as FormDescription } from './FormDescription.vue'
|
||||
export { default as FormItem } from './FormItem.vue'
|
||||
export { default as FormLabel } from './FormLabel.vue'
|
||||
export { default as FormMessage } from './FormMessage.vue'
|
||||
export { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
|
||||
export { Form, Field as FormField } from 'vee-validate'
|
||||
5
src/components/ui/form/injectionKeys.ts
Normal file
5
src/components/ui/form/injectionKeys.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export const FORM_ITEM_INJECTION_KEY
|
||||
// eslint-disable-next-line symbol-description
|
||||
= Symbol() as InjectionKey<string>
|
||||
30
src/components/ui/form/useFormField.ts
Normal file
30
src/components/ui/form/useFormField.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { FieldContextKey, useFieldError, useIsFieldDirty, useIsFieldTouched, useIsFieldValid } from 'vee-validate'
|
||||
import { inject } from 'vue'
|
||||
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
|
||||
|
||||
export function useFormField() {
|
||||
const fieldContext = inject(FieldContextKey)
|
||||
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
|
||||
|
||||
if (!fieldContext)
|
||||
throw new Error('useFormField should be used within <FormField>')
|
||||
|
||||
const { name } = fieldContext
|
||||
const id = fieldItemContext
|
||||
|
||||
const fieldState = {
|
||||
valid: useIsFieldValid(name),
|
||||
isDirty: useIsFieldDirty(name),
|
||||
isTouched: useIsFieldTouched(name),
|
||||
error: useFieldError(name),
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
15
src/components/ui/popover/Popover.vue
Normal file
15
src/components/ui/popover/Popover.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from 'radix-vue'
|
||||
import { PopoverRoot, useForwardPropsEmits } from 'radix-vue'
|
||||
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
48
src/components/ui/popover/PopoverContent.vue
Normal file
48
src/components/ui/popover/PopoverContent.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverContent,
|
||||
type PopoverContentEmits,
|
||||
type PopoverContentProps,
|
||||
PopoverPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
align: 'center',
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
11
src/components/ui/popover/PopoverTrigger.vue
Normal file
11
src/components/ui/popover/PopoverTrigger.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { PopoverTrigger, type PopoverTriggerProps } from 'radix-vue'
|
||||
|
||||
const props = defineProps<PopoverTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger v-bind="props">
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
3
src/components/ui/popover/index.ts
Normal file
3
src/components/ui/popover/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as Popover } from './Popover.vue'
|
||||
export { default as PopoverContent } from './PopoverContent.vue'
|
||||
export { default as PopoverTrigger } from './PopoverTrigger.vue'
|
||||
25
src/components/ui/radio-group/RadioGroup.vue
Normal file
25
src/components/ui/radio-group/RadioGroup.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioGroupRoot, type RadioGroupRootEmits, type RadioGroupRootProps, useForwardPropsEmits } from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<RadioGroupRootEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroupRoot
|
||||
:class="cn('grid gap-2', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</RadioGroupRoot>
|
||||
</template>
|
||||
39
src/components/ui/radio-group/RadioGroupItem.vue
Normal file
39
src/components/ui/radio-group/RadioGroupItem.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { Circle } from 'lucide-vue-next'
|
||||
import {
|
||||
RadioGroupIndicator,
|
||||
RadioGroupItem,
|
||||
type RadioGroupItemProps,
|
||||
useForwardProps,
|
||||
} from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RadioGroupItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<RadioGroupIndicator
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<Circle class="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupIndicator>
|
||||
</RadioGroupItem>
|
||||
</template>
|
||||
2
src/components/ui/radio-group/index.ts
Normal file
2
src/components/ui/radio-group/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as RadioGroup } from './RadioGroup.vue'
|
||||
export { default as RadioGroupItem } from './RadioGroupItem.vue'
|
||||
39
src/components/ui/switch/Switch.vue
Normal file
39
src/components/ui/switch/Switch.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
SwitchRoot,
|
||||
type SwitchRootEmits,
|
||||
type SwitchRootProps,
|
||||
SwitchThumb,
|
||||
useForwardPropsEmits,
|
||||
} from 'radix-vue'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const emits = defineEmits<SwitchRootEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="cn('pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5')"
|
||||
>
|
||||
<slot name="thumb" />
|
||||
</SwitchThumb>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
1
src/components/ui/switch/index.ts
Normal file
1
src/components/ui/switch/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Switch } from './Switch.vue'
|
||||
24
src/components/ui/textarea/Textarea.vue
Normal file
24
src/components/ui/textarea/Textarea.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea v-model="modelValue" :class="cn('flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)" />
|
||||
</template>
|
||||
1
src/components/ui/textarea/index.ts
Normal file
1
src/components/ui/textarea/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Textarea } from './Textarea.vue'
|
||||
@ -7,6 +7,7 @@ meta:
|
||||
</route>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
import { API_BASE_URL } from '~/CONSTANT'
|
||||
|
||||
const selectedMode = ref('观测的二阶多项式拟合')
|
||||
@ -19,16 +20,30 @@ const modes = [
|
||||
'扰动分量的正弦波拟合',
|
||||
'径向风-纬向风矢量图',
|
||||
'温度-水平风矢量图',
|
||||
]
|
||||
] as const
|
||||
const formSchema = shallowRef<z.ZodObject< any, any, any > | null>(null)
|
||||
const stagedDates = ref<string[]>([])
|
||||
|
||||
const selectedDate = ref('2022-01-01')
|
||||
|
||||
const dates = ref([] as string[])
|
||||
|
||||
onMounted(async () => {
|
||||
const resp = await fetch(`${API_BASE_URL}/balloon/metadata`)
|
||||
const data = await resp.json()
|
||||
dates.value = data
|
||||
await fetch(`${API_BASE_URL}/balloon/metadata`).then(resp => resp.json()).then((data) => {
|
||||
stagedDates.value = data
|
||||
formSchema.value = z.object({
|
||||
selectedMode: z.enum(modes),
|
||||
selectedDate: z.enum(data.map((d: string) => {
|
||||
const datePattern = /_\d{8}T\d{6}/
|
||||
if (!datePattern.test(d)) {
|
||||
return ''
|
||||
}
|
||||
const capture = datePattern.exec(d)
|
||||
if (!capture) {
|
||||
return ''
|
||||
}
|
||||
return capture[0]
|
||||
})),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function get_image() {
|
||||
@ -49,49 +64,27 @@ async function get_image() {
|
||||
watch([selectedMode, selectedDate], () => {
|
||||
get_image()
|
||||
})
|
||||
|
||||
function submit(value: Record<string, any>) {
|
||||
if (!(value.selectedMode && value.selectedDate)) {
|
||||
return
|
||||
}
|
||||
selectedMode.value = value.selectedMode
|
||||
// search for the date in stagedDates
|
||||
for (const d of stagedDates.value) {
|
||||
if (d.includes(value.selectedDate)) {
|
||||
selectedDate.value = d
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ row items-center">
|
||||
<div>
|
||||
<div flex="~ col items-center gap-3" py-3>
|
||||
<Select v-model="selectedMode">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>计算模式</SelectLabel>
|
||||
<SelectItem v-for="mode in modes" :key="mode" :value="mode">
|
||||
{{ mode }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-model="selectedDate">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>天</SelectLabel>
|
||||
<SelectItem v-for="date in dates" :key="date" :value="date">
|
||||
{{ date.match(/_(\d{8}T\d{6})/)?.[1] }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="hasWaveThisDay">
|
||||
<Image :image-url="imageUrl" />
|
||||
</div>
|
||||
<div v-else h-40 flex="~ col items-center justify-center" text-xl>
|
||||
{{ selectedDate.match(/_(\d{8}T\d{6})/)?.[1] }} <br>
|
||||
这个时刻没有数据
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row items-begin justify-stretch gap-3 w-full">
|
||||
<ParamsCard :schema="formSchema" @submit="(e) => submit(e)" />
|
||||
|
||||
<ImageContainer :image-url="imageUrl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user