@byyuurin/ui 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -3
- package/dist/module.mjs.map +1 -1
- package/dist/runtime/app/injections.d.ts +9299 -8
- package/dist/runtime/app/injections.js +35 -5
- package/dist/runtime/components/Accordion.vue +17 -19
- package/dist/runtime/components/Alert.vue +6 -9
- package/dist/runtime/components/App.vue +14 -19
- package/dist/runtime/components/Avatar.vue +5 -8
- package/dist/runtime/components/AvatarGroup.vue +2 -5
- package/dist/runtime/components/Badge.vue +3 -6
- package/dist/runtime/components/Breadcrumb.vue +22 -23
- package/dist/runtime/components/Button.vue +2 -3
- package/dist/runtime/components/ButtonGroup.vue +2 -5
- package/dist/runtime/components/Calendar.vue +185 -0
- package/dist/runtime/components/Card.vue +2 -5
- package/dist/runtime/components/Carousel.vue +11 -16
- package/dist/runtime/components/Checkbox.vue +13 -11
- package/dist/runtime/components/Chip.vue +6 -9
- package/dist/runtime/components/Collapsible.vue +2 -5
- package/dist/runtime/components/Drawer.vue +88 -45
- package/dist/runtime/components/DropdownMenu.vue +143 -0
- package/dist/runtime/components/DropdownMenuContent.vue +188 -0
- package/dist/runtime/components/Form.vue +311 -0
- package/dist/runtime/components/FormItem.vue +129 -0
- package/dist/runtime/components/Input.vue +34 -23
- package/dist/runtime/components/InputNumber.vue +23 -18
- package/dist/runtime/components/Kbd.vue +2 -6
- package/dist/runtime/components/Link.vue +25 -7
- package/dist/runtime/components/Modal.vue +31 -22
- package/dist/runtime/components/OverlayProvider.vue +29 -0
- package/dist/runtime/components/Pagination.vue +34 -27
- package/dist/runtime/components/PinInput.vue +23 -17
- package/dist/runtime/components/Popover.vue +5 -8
- package/dist/runtime/components/Progress.vue +2 -5
- package/dist/runtime/components/RadioGroup.vue +52 -49
- package/dist/runtime/components/ScrollArea.vue +2 -6
- package/dist/runtime/components/Select.vue +96 -89
- package/dist/runtime/components/Separator.vue +2 -6
- package/dist/runtime/components/Skeleton.vue +2 -5
- package/dist/runtime/components/Slider.vue +13 -11
- package/dist/runtime/components/Switch.vue +18 -11
- package/dist/runtime/components/Table.vue +23 -13
- package/dist/runtime/components/Tabs.vue +14 -16
- package/dist/runtime/components/Textarea.vue +20 -17
- package/dist/runtime/components/Toast.vue +12 -13
- package/dist/runtime/components/{Toaster.vue → ToastProvider.vue} +18 -16
- package/dist/runtime/components/Tooltip.vue +5 -8
- package/dist/runtime/composables/useFormItem.d.ts +27 -0
- package/dist/runtime/composables/useFormItem.js +64 -0
- package/dist/runtime/composables/useKbd.d.ts +1 -1
- package/dist/runtime/composables/useOverlay.d.ts +29 -0
- package/dist/runtime/composables/useOverlay.js +69 -0
- package/dist/runtime/composables/useTheme.d.ts +6 -2
- package/dist/runtime/composables/useTheme.js +10 -3
- package/dist/runtime/composables/useToast.d.ts +4 -20
- package/dist/runtime/composables/useToast.js +6 -5
- package/dist/runtime/index.d.ts +6 -2
- package/dist/runtime/index.js +6 -2
- package/dist/runtime/locale/en.js +6 -0
- package/dist/runtime/locale/zh-tw.js +6 -0
- package/dist/runtime/theme/accordion.js +3 -3
- package/dist/runtime/theme/alert.js +3 -3
- package/dist/runtime/theme/app.d.ts +1 -0
- package/dist/runtime/theme/app.js +2 -1
- package/dist/runtime/theme/avatar.js +2 -2
- package/dist/runtime/theme/badge.d.ts +21 -45
- package/dist/runtime/theme/breadcrumb.d.ts +3 -3
- package/dist/runtime/theme/breadcrumb.js +5 -5
- package/dist/runtime/theme/button.d.ts +111 -57
- package/dist/runtime/theme/button.js +13 -13
- package/dist/runtime/theme/calendar.d.ts +56 -0
- package/dist/runtime/theme/calendar.js +69 -0
- package/dist/runtime/theme/card.js +6 -6
- package/dist/runtime/theme/carousel.js +1 -1
- package/dist/runtime/theme/checkbox.js +5 -5
- package/dist/runtime/theme/chip.d.ts +11 -44
- package/dist/runtime/theme/chip.js +3 -3
- package/dist/runtime/theme/drawer.d.ts +85 -47
- package/dist/runtime/theme/drawer.js +46 -19
- package/dist/runtime/theme/dropdown-menu.d.ts +71 -0
- package/dist/runtime/theme/dropdown-menu.js +83 -0
- package/dist/runtime/theme/form-item.d.ts +76 -0
- package/dist/runtime/theme/form-item.js +34 -0
- package/dist/runtime/theme/form.d.ts +8 -0
- package/dist/runtime/theme/form.js +7 -0
- package/dist/runtime/theme/index.d.ts +5 -1
- package/dist/runtime/theme/index.js +5 -1
- package/dist/runtime/theme/input-number.d.ts +41 -61
- package/dist/runtime/theme/input-number.js +1 -1
- package/dist/runtime/theme/input.d.ts +99 -71
- package/dist/runtime/theme/input.js +15 -15
- package/dist/runtime/theme/kbd.d.ts +2 -2
- package/dist/runtime/theme/kbd.js +1 -1
- package/dist/runtime/theme/link.d.ts +1 -1
- package/dist/runtime/theme/link.js +3 -3
- package/dist/runtime/theme/modal.d.ts +5 -33
- package/dist/runtime/theme/modal.js +4 -4
- package/dist/runtime/theme/pagination.d.ts +27 -3
- package/dist/runtime/theme/pagination.js +6 -2
- package/dist/runtime/theme/pinInput.d.ts +42 -42
- package/dist/runtime/theme/pinInput.js +13 -13
- package/dist/runtime/theme/progress.d.ts +117 -53
- package/dist/runtime/theme/progress.js +4 -4
- package/dist/runtime/theme/radio-group.d.ts +2 -2
- package/dist/runtime/theme/radio-group.js +7 -7
- package/dist/runtime/theme/select.d.ts +100 -84
- package/dist/runtime/theme/select.js +21 -20
- package/dist/runtime/theme/separator.d.ts +13 -28
- package/dist/runtime/theme/separator.js +1 -1
- package/dist/runtime/theme/skeleton.d.ts +1 -1
- package/dist/runtime/theme/skeleton.js +1 -1
- package/dist/runtime/theme/slider.js +1 -1
- package/dist/runtime/theme/switch.js +5 -5
- package/dist/runtime/theme/table.d.ts +3 -0
- package/dist/runtime/theme/table.js +8 -7
- package/dist/runtime/theme/tabs.d.ts +51 -68
- package/dist/runtime/theme/tabs.js +10 -10
- package/dist/runtime/theme/textarea.d.ts +37 -43
- package/dist/runtime/theme/textarea.js +13 -13
- package/dist/runtime/theme/{toaster.d.ts → toast-provider.d.ts} +26 -41
- package/dist/runtime/theme/toast.js +5 -5
- package/dist/runtime/theme/tooltip.js +1 -1
- package/dist/runtime/types/components.d.ts +6 -1
- package/dist/runtime/types/form.d.ts +45 -0
- package/dist/runtime/types/form.js +0 -0
- package/dist/runtime/types/index.d.ts +5 -2
- package/dist/runtime/types/index.js +1 -0
- package/dist/runtime/types/locale.d.ts +6 -0
- package/dist/runtime/types/utils.d.ts +35 -12
- package/dist/runtime/utils/extend-theme.js +15 -4
- package/dist/runtime/utils/form.d.ts +5 -0
- package/dist/runtime/utils/form.js +24 -0
- package/dist/runtime/utils/index.d.ts +2 -0
- package/dist/runtime/utils/index.js +4 -0
- package/dist/runtime/utils/link.d.ts +4 -26
- package/dist/runtime/utils/link.js +10 -3
- package/dist/shared/ui.3e7fad19.mjs +5 -0
- package/dist/shared/ui.3e7fad19.mjs.map +1 -0
- package/dist/unocss.mjs +21 -16
- package/dist/unocss.mjs.map +1 -1
- package/dist/unplugin.mjs +1 -1
- package/dist/vite.mjs +1 -1
- package/package.json +20 -18
- package/dist/runtime/components/ModalProvider.vue +0 -11
- package/dist/runtime/composables/useModal.d.ts +0 -10
- package/dist/runtime/composables/useModal.js +0 -47
- package/dist/shared/ui.ba24b380.mjs +0 -4
- package/dist/shared/ui.ba24b380.mjs.map +0 -1
- /package/dist/runtime/theme/{toaster.js → toast-provider.js} +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { VariantProps } from '@byyuurin/ui-kit'
|
|
3
|
+
import type { DropdownMenuContentEmits as RekaDropdownMenuContentEmits, DropdownMenuContentProps as RekaDropdownMenuContentProps } from 'reka-ui'
|
|
4
|
+
import type { dropdownMenu } from '../theme'
|
|
5
|
+
import type { ArrayOrNested, ComponentAttrs, DropdownMenuItem, DropdownMenuSlots, NestedItem } from '../types'
|
|
6
|
+
|
|
7
|
+
type ExtractItem<T extends ArrayOrNested<any>> = Extract<NestedItem<T>, { slot: string }>
|
|
8
|
+
|
|
9
|
+
export type DropdownMenuContentSlots<T extends ArrayOrNested<DropdownMenuItem>> = Omit<DropdownMenuSlots<T>, 'default'> & {
|
|
10
|
+
default?: any
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DropdownMenuContentEmits extends RekaDropdownMenuContentEmits {}
|
|
14
|
+
|
|
15
|
+
type DropdownMenuVariants = VariantProps<typeof dropdownMenu>
|
|
16
|
+
|
|
17
|
+
export interface DropdownMenuContentProps<T extends ArrayOrNested<DropdownMenuItem>> extends ComponentAttrs<typeof dropdownMenu>, Omit<RekaDropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> {
|
|
18
|
+
size?: DropdownMenuVariants['size']
|
|
19
|
+
items?: T
|
|
20
|
+
portal?: boolean
|
|
21
|
+
sub?: boolean
|
|
22
|
+
labelKey: keyof NestedItem<T>
|
|
23
|
+
checkedIcon?: string
|
|
24
|
+
loadingIcon?: string
|
|
25
|
+
externalIcon?: boolean | string
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts" generic="T extends ArrayOrNested<DropdownMenuItem>">
|
|
30
|
+
import { createReusableTemplate, reactiveOmit } from '@vueuse/core'
|
|
31
|
+
import { useForwardPropsEmits } from 'reka-ui'
|
|
32
|
+
import { DropdownMenu } from 'reka-ui/namespaced'
|
|
33
|
+
import { computed } from 'vue'
|
|
34
|
+
import { useTheme } from '../composables/useTheme'
|
|
35
|
+
import { get, isArrayOfArray, omit } from '../utils'
|
|
36
|
+
import { pickLinkProps } from '../utils/link'
|
|
37
|
+
import Avatar from './Avatar.vue'
|
|
38
|
+
// eslint-disable-next-line import/no-self-import
|
|
39
|
+
import DropdownMenuContent from './DropdownMenuContent.vue'
|
|
40
|
+
import Kbd from './Kbd.vue'
|
|
41
|
+
import Link from './Link.vue'
|
|
42
|
+
import LinkBase from './LinkBase.vue'
|
|
43
|
+
|
|
44
|
+
const props = defineProps<DropdownMenuContentProps<T>>()
|
|
45
|
+
const emit = defineEmits<DropdownMenuContentEmits>()
|
|
46
|
+
const slots = defineSlots<DropdownMenuContentSlots<T>>()
|
|
47
|
+
|
|
48
|
+
const contentProps = useForwardPropsEmits(reactiveOmit(props, 'sub', 'items', 'portal', 'labelKey', 'checkedIcon', 'loadingIcon', 'externalIcon', 'class', 'ui'), emit)
|
|
49
|
+
// @ts-expect-error pass check
|
|
50
|
+
const proxySlots = omit(slots, ['default']) as Record<string, DropdownMenuContentSlots<T>[string]>
|
|
51
|
+
|
|
52
|
+
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: DropdownMenuItem, active?: boolean, index: number }>()
|
|
53
|
+
|
|
54
|
+
const groups = computed(
|
|
55
|
+
() => props.items?.length
|
|
56
|
+
? isArrayOfArray(props.items) ? props.items : [props.items]
|
|
57
|
+
: [],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const { theme, generateStyle } = useTheme()
|
|
61
|
+
const style = computed(() => generateStyle('dropdownMenu', props))
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<DefineItemTemplate v-slot="{ item, active, index }">
|
|
66
|
+
<slot :name="((item.slot || 'item') as keyof DropdownMenuContentSlots<T>)" :item="(item as ExtractItem<T>)" :index="index">
|
|
67
|
+
<slot :name="(`${item.slot || 'item'}-leading` as keyof DropdownMenuContentSlots<T>)" :item="(item as ExtractItem<T>)" :active="active" :index="index">
|
|
68
|
+
<span
|
|
69
|
+
v-if="item.loading"
|
|
70
|
+
:class="style.itemLeadingIcon({ class: [loadingIcon || theme.app.icons.loading, props.ui?.itemLeadingIcon], loading: true })"
|
|
71
|
+
></span>
|
|
72
|
+
<span
|
|
73
|
+
v-else-if="item.icon"
|
|
74
|
+
:class="style.itemLeadingIcon({ class: [item.icon, props.ui?.itemLeadingIcon], active })"
|
|
75
|
+
></span>
|
|
76
|
+
<Avatar
|
|
77
|
+
v-else-if="item.avatar"
|
|
78
|
+
v-bind="item.avatar"
|
|
79
|
+
:size="item.avatar.size || props.size"
|
|
80
|
+
:class="style.itemLeadingAvatar({ class: props.ui?.itemLeadingAvatar, active })"
|
|
81
|
+
/>
|
|
82
|
+
</slot>
|
|
83
|
+
|
|
84
|
+
<span
|
|
85
|
+
v-if="get(item, props.labelKey as string) || !!slots[(`${item.slot || 'item'}-label` as keyof DropdownMenuContentSlots<T>)]"
|
|
86
|
+
:class="style.itemLabel({ class: props.ui?.itemLabel, active })"
|
|
87
|
+
>
|
|
88
|
+
<slot :name="(`${item.slot || 'item'}-label` as keyof DropdownMenuContentSlots<T>)" :item="(item as ExtractItem<T>)" :active="active" :index="index">
|
|
89
|
+
{{ get(item, props.labelKey as string) }}
|
|
90
|
+
</slot>
|
|
91
|
+
|
|
92
|
+
<span
|
|
93
|
+
v-if="item.target === '_blank' && externalIcon !== false"
|
|
94
|
+
:class="style.itemLabelExternalIcon({ class: [typeof externalIcon === 'string' ? externalIcon : theme.app.icons.external, props.ui?.itemLabelExternalIcon], active })"
|
|
95
|
+
></span>
|
|
96
|
+
</span>
|
|
97
|
+
|
|
98
|
+
<span :class="style.itemTrailing({ class: props.ui?.itemTrailing })">
|
|
99
|
+
<slot :name="(`${item.slot || 'item'}-trailing` as keyof DropdownMenuContentSlots<T>)" :item="(item as ExtractItem<T>)" :active="active" :index="index">
|
|
100
|
+
<span v-if="item.children?.length" :class="style.itemTrailingIcon({ class: [theme.app.icons.chevronRight, props.ui?.itemTrailingIcon], active })"></span>
|
|
101
|
+
<span v-else-if="item.kbds?.length" :class="style.itemTrailingKbds({ class: props.ui?.itemTrailingKbds })">
|
|
102
|
+
<Kbd
|
|
103
|
+
v-for="(kbd, kbdIndex) in item.kbds"
|
|
104
|
+
:key="kbdIndex"
|
|
105
|
+
v-bind="typeof kbd === 'string' ? { value: kbd } : kbd"
|
|
106
|
+
:size="props.size"
|
|
107
|
+
/>
|
|
108
|
+
</span>
|
|
109
|
+
</slot>
|
|
110
|
+
|
|
111
|
+
<DropdownMenu.ItemIndicator as-child>
|
|
112
|
+
<span :class="style.itemTrailingIcon({ class: [checkedIcon || theme.app.icons.check, props.ui?.itemTrailingIcon] })"></span>
|
|
113
|
+
</DropdownMenu.ItemIndicator>
|
|
114
|
+
</span>
|
|
115
|
+
</slot>
|
|
116
|
+
</DefineItemTemplate>
|
|
117
|
+
|
|
118
|
+
<DropdownMenu.Portal :disabled="!portal">
|
|
119
|
+
<component :is="sub ? DropdownMenu.SubContent : DropdownMenu.Content" :class="props.class" v-bind="contentProps">
|
|
120
|
+
<DropdownMenu.Group v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="style.group({ class: props.ui?.group })">
|
|
121
|
+
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
|
|
122
|
+
<DropdownMenu.Label v-if="item.type === 'label'" :class="style.label({ class: props.ui?.label })">
|
|
123
|
+
<ReuseItemTemplate :item="item" :index="index" />
|
|
124
|
+
</DropdownMenu.Label>
|
|
125
|
+
<DropdownMenu.Separator v-else-if="item.type === 'separator'" :class="style.separator({ class: props.ui?.separator })" />
|
|
126
|
+
<DropdownMenu.Sub v-else-if="item?.children?.length" :open="item.open" :default-open="item.defaultOpen">
|
|
127
|
+
<DropdownMenu.SubTrigger
|
|
128
|
+
as="button"
|
|
129
|
+
type="button"
|
|
130
|
+
:disabled="item.disabled"
|
|
131
|
+
:text-value="get(item, props.labelKey as string)"
|
|
132
|
+
:class="style.item({ class: props.ui?.item })"
|
|
133
|
+
>
|
|
134
|
+
<ReuseItemTemplate :item="item" :index="index" />
|
|
135
|
+
</DropdownMenu.SubTrigger>
|
|
136
|
+
|
|
137
|
+
<DropdownMenuContent
|
|
138
|
+
sub
|
|
139
|
+
:class="props.class"
|
|
140
|
+
:ui="props.ui"
|
|
141
|
+
:portal="props.portal"
|
|
142
|
+
:items="item.children"
|
|
143
|
+
side="right"
|
|
144
|
+
align="start"
|
|
145
|
+
:align-offset="-4"
|
|
146
|
+
:side-offset="3"
|
|
147
|
+
:label-key="labelKey"
|
|
148
|
+
:checked-icon="checkedIcon"
|
|
149
|
+
:loading-icon="loadingIcon"
|
|
150
|
+
:external-icon="externalIcon"
|
|
151
|
+
v-bind="item.content"
|
|
152
|
+
>
|
|
153
|
+
<template v-for="(_, name) in proxySlots" #[name]="slotProps">
|
|
154
|
+
<slot :name="(name as keyof DropdownMenuContentSlots<T>)" v-bind="slotProps"></slot>
|
|
155
|
+
</template>
|
|
156
|
+
</DropdownMenuContent>
|
|
157
|
+
</DropdownMenu.Sub>
|
|
158
|
+
<DropdownMenu.CheckboxItem
|
|
159
|
+
v-else-if="item.type === 'checkbox'"
|
|
160
|
+
:model-value="item.checked"
|
|
161
|
+
:disabled="item.disabled"
|
|
162
|
+
:text-value="get(item, props.labelKey as string)"
|
|
163
|
+
:class="style.item({ class: [props.ui?.item, item.class] })"
|
|
164
|
+
@update:model-value="item.onUpdateChecked"
|
|
165
|
+
@select="item.onSelect"
|
|
166
|
+
>
|
|
167
|
+
<ReuseItemTemplate :item="item" :index="index" />
|
|
168
|
+
</DropdownMenu.CheckboxItem>
|
|
169
|
+
<DropdownMenu.Item
|
|
170
|
+
v-else
|
|
171
|
+
as-child
|
|
172
|
+
:disabled="item.disabled"
|
|
173
|
+
:text-value="get(item, props.labelKey as string)"
|
|
174
|
+
@select="item.onSelect"
|
|
175
|
+
>
|
|
176
|
+
<Link v-slot="{ active, ...slotProps }" v-bind="pickLinkProps(item as Omit<DropdownMenuItem, 'type'>)" custom>
|
|
177
|
+
<LinkBase v-bind="slotProps" :class="style.item({ class: [props.ui?.item, item.class], active })">
|
|
178
|
+
<ReuseItemTemplate :item="item" :active="active" :index="index" />
|
|
179
|
+
</LinkBase>
|
|
180
|
+
</Link>
|
|
181
|
+
</DropdownMenu.Item>
|
|
182
|
+
</template>
|
|
183
|
+
</DropdownMenu.Group>
|
|
184
|
+
|
|
185
|
+
<slot></slot>
|
|
186
|
+
</component>
|
|
187
|
+
</DropdownMenu.Portal>
|
|
188
|
+
</template>
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ComputedRef, DeepReadonly, Ref } from 'vue'
|
|
3
|
+
import type { form } from '../theme'
|
|
4
|
+
import type { ComponentAttrs, FormError, FormErrorEvent, FormErrorWithId, FormEvent, FormInputEvents, FormSchema, FormSubmitEvent, FormValidateOptions } from '../types'
|
|
5
|
+
|
|
6
|
+
export interface FormEmits<T extends object> {
|
|
7
|
+
submit: [payload: FormSubmitEvent<T>]
|
|
8
|
+
error: [payload: FormErrorEvent]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FormSlots {
|
|
12
|
+
default?: (props?: { errors: FormError[] }) => any
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FormExpose<T extends object> {
|
|
16
|
+
validate: (options?: FormValidateOptions<T>) => Promise<T | false>
|
|
17
|
+
clear: (path?: string) => void
|
|
18
|
+
errors: Ref<FormError[]>
|
|
19
|
+
setErrors: (errors: FormError[], name?: keyof T) => void
|
|
20
|
+
getErrors: (name?: keyof T) => FormError[]
|
|
21
|
+
submit: () => Promise<void>
|
|
22
|
+
disabled: ComputedRef<boolean>
|
|
23
|
+
dirty: ComputedRef<boolean>
|
|
24
|
+
dirtyFields: DeepReadonly<Set<keyof T>>
|
|
25
|
+
touchedFields: DeepReadonly<Set<keyof T>>
|
|
26
|
+
blurredFields: DeepReadonly<Set<keyof T>>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FormProps<T extends object> extends Omit<ComponentAttrs<typeof form>, 'ui'> {
|
|
30
|
+
id?: string | number
|
|
31
|
+
/** Schema to validate the form state. */
|
|
32
|
+
schema?: FormSchema<T>
|
|
33
|
+
/** An object representing the current state of the form. */
|
|
34
|
+
state: Partial<T>
|
|
35
|
+
/**
|
|
36
|
+
* Custom validation function to validate the form state.
|
|
37
|
+
* @param state - The current state of the form.
|
|
38
|
+
* @returns A promise that resolves to an array of FormError objects, or an array of FormError objects directly.
|
|
39
|
+
*/
|
|
40
|
+
validate?: (state: Partial<T>) => Promise<FormError[]> | FormError[]
|
|
41
|
+
/**
|
|
42
|
+
* The list of input events that trigger the form validation.
|
|
43
|
+
* @default ["blur", "change", "input"]
|
|
44
|
+
*/
|
|
45
|
+
validateOn?: FormInputEvents[]
|
|
46
|
+
/** Disable all inputs inside the form. */
|
|
47
|
+
disabled?: boolean
|
|
48
|
+
/**
|
|
49
|
+
* Delay in milliseconds before validating the form on input events.
|
|
50
|
+
* @default 300
|
|
51
|
+
*/
|
|
52
|
+
validateOnInputDelay?: number
|
|
53
|
+
/**
|
|
54
|
+
* If true, schema transformations will be applied to the state on submit.
|
|
55
|
+
* @default true
|
|
56
|
+
*/
|
|
57
|
+
transform?: boolean
|
|
58
|
+
onSubmit?: ((event: FormSubmitEvent<T>) => void | Promise<void>) | (() => void | Promise<void>)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class FormValidationExceptionError extends Error {
|
|
62
|
+
formId: string | number
|
|
63
|
+
errors: FormErrorWithId[]
|
|
64
|
+
children?: FormValidationExceptionError[]
|
|
65
|
+
|
|
66
|
+
constructor(formId: string | number, errors: FormErrorWithId[], childErrors?: FormValidationExceptionError[]) {
|
|
67
|
+
super('Form validation exception')
|
|
68
|
+
this.name = 'FormValidationExceptionError'
|
|
69
|
+
this.formId = formId
|
|
70
|
+
this.errors = errors
|
|
71
|
+
this.children = childErrors
|
|
72
|
+
Object.setPrototypeOf(this, FormValidationExceptionError.prototype)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<script setup lang="ts" generic="T extends object">
|
|
78
|
+
import { useEventBus } from '@vueuse/core'
|
|
79
|
+
import { computed, nextTick, onMounted, onUnmounted, readonly, ref, useId } from 'vue'
|
|
80
|
+
import { injectFormBus, provideFormBus, provideFormErrors, provideFormInputs, provideFormLoading, provideFormOptions } from '../app/injections'
|
|
81
|
+
import { useTheme } from '../composables/useTheme'
|
|
82
|
+
import { validateSchema } from '../utils'
|
|
83
|
+
|
|
84
|
+
const props = withDefaults(defineProps<FormProps<T>>(), {
|
|
85
|
+
validateOn: () => ['input', 'blur', 'change'],
|
|
86
|
+
validateOnInputDelay: 300,
|
|
87
|
+
transform: true,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const emit = defineEmits<FormEmits<T>>()
|
|
91
|
+
defineSlots<FormSlots>()
|
|
92
|
+
|
|
93
|
+
const formId = props.id ?? useId()
|
|
94
|
+
|
|
95
|
+
const bus = useEventBus<FormEvent<T>>(`form-${formId}`)
|
|
96
|
+
const parentBus = injectFormBus()
|
|
97
|
+
|
|
98
|
+
provideFormBus(bus)
|
|
99
|
+
|
|
100
|
+
const nestedForms = ref<Map<string | number, { validate: typeof validateFn }>>(new Map())
|
|
101
|
+
|
|
102
|
+
onMounted(() => {
|
|
103
|
+
bus.on(async (event) => {
|
|
104
|
+
if (event.type === 'attach') {
|
|
105
|
+
nestedForms.value.set(event.formId, { validate: event.validate })
|
|
106
|
+
}
|
|
107
|
+
else if (event.type === 'detach') {
|
|
108
|
+
nestedForms.value.delete(event.formId)
|
|
109
|
+
}
|
|
110
|
+
else if (props.validateOn?.includes(event.type) && !loading.value) {
|
|
111
|
+
if (event.type !== 'input')
|
|
112
|
+
await validateFn({ name: event.name, silent: true, nested: false })
|
|
113
|
+
else if (event.eager || blurredFields.has(event.name))
|
|
114
|
+
await validateFn({ name: event.name, silent: true, nested: false })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (event.type === 'blur')
|
|
118
|
+
blurredFields.add(event.name)
|
|
119
|
+
|
|
120
|
+
if (event.type === 'change' || event.type === 'input' || event.type === 'blur' || event.type === 'focus')
|
|
121
|
+
touchedFields.add(event.name)
|
|
122
|
+
|
|
123
|
+
if (event.type === 'change' || event.type === 'input')
|
|
124
|
+
dirtyFields.add(event.name)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
onUnmounted(() => {
|
|
129
|
+
bus.reset()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
onMounted(async () => {
|
|
133
|
+
if (parentBus) {
|
|
134
|
+
await nextTick()
|
|
135
|
+
parentBus.emit({ type: 'attach', validate: validateFn as FormExpose<T>['validate'], formId })
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
onUnmounted(() => {
|
|
140
|
+
if (parentBus)
|
|
141
|
+
parentBus.emit({ type: 'detach', formId })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const errors = ref<FormErrorWithId[]>([])
|
|
145
|
+
provideFormErrors(errors)
|
|
146
|
+
|
|
147
|
+
const inputs = ref<{ [P in keyof T]?: { id?: string, pattern?: RegExp } }>({})
|
|
148
|
+
provideFormInputs(inputs as any)
|
|
149
|
+
|
|
150
|
+
const dirtyFields = new Set<keyof T>()
|
|
151
|
+
const touchedFields = new Set<keyof T>()
|
|
152
|
+
const blurredFields = new Set<keyof T>()
|
|
153
|
+
|
|
154
|
+
function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
|
|
155
|
+
return errs.map((err) => ({
|
|
156
|
+
...err,
|
|
157
|
+
id: err?.name ? inputs.value[err.name]?.id : undefined,
|
|
158
|
+
}))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const transformedState = ref<T | null>(null)
|
|
162
|
+
|
|
163
|
+
async function getErrors(): Promise<FormErrorWithId[]> {
|
|
164
|
+
let errs = props.validate ? (await props.validate(props.state)) ?? [] : []
|
|
165
|
+
|
|
166
|
+
if (props.schema) {
|
|
167
|
+
const { errors, result } = await validateSchema(props.state, props.schema as FormSchema<typeof props.state>)
|
|
168
|
+
|
|
169
|
+
if (errors)
|
|
170
|
+
errs = errs.concat(errors)
|
|
171
|
+
else
|
|
172
|
+
transformedState.value = result
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return resolveErrorIds(errs)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function validateFn(options: FormValidateOptions<T>): Promise<T | false> {
|
|
179
|
+
const _options: FormValidateOptions<T> = {
|
|
180
|
+
silent: false,
|
|
181
|
+
nested: true,
|
|
182
|
+
transform: false,
|
|
183
|
+
...options,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const names = _options.name && !Array.isArray(_options.name) ? [_options.name] : _options.name as (keyof T)[]
|
|
187
|
+
|
|
188
|
+
const nestedValidatePromises = !names && _options.nested
|
|
189
|
+
? Array.from(nestedForms.value.values()).map(
|
|
190
|
+
({ validate }) => validate(_options).then(() => {}).catch((error: Error) => {
|
|
191
|
+
if (!(error instanceof FormValidationExceptionError))
|
|
192
|
+
throw error
|
|
193
|
+
|
|
194
|
+
return error
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
: []
|
|
198
|
+
|
|
199
|
+
if (names) {
|
|
200
|
+
const otherErrors = errors.value.filter((error) => !names.some((name) => {
|
|
201
|
+
const pattern = inputs.value?.[name]?.pattern
|
|
202
|
+
return name === error.name || (pattern && error.name?.match(pattern))
|
|
203
|
+
}))
|
|
204
|
+
|
|
205
|
+
// eslint-disable-next-line unicorn/no-await-expression-member
|
|
206
|
+
const pathErrors = (await getErrors()).filter((error) => names.some((name) => {
|
|
207
|
+
const pattern = inputs.value?.[name]?.pattern
|
|
208
|
+
return name === error.name || (pattern && error.name?.match(pattern))
|
|
209
|
+
}))
|
|
210
|
+
|
|
211
|
+
errors.value = otherErrors.concat(pathErrors)
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
errors.value = await getErrors()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// eslint-disable-next-line unicorn/no-await-expression-member
|
|
218
|
+
const childErrors = (await Promise.all(nestedValidatePromises)).filter((val) => val !== undefined)
|
|
219
|
+
|
|
220
|
+
if (errors.value.length + childErrors.length > 0) {
|
|
221
|
+
if (_options.silent)
|
|
222
|
+
return false
|
|
223
|
+
|
|
224
|
+
throw new FormValidationExceptionError(formId, errors.value, childErrors)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (_options.transform)
|
|
228
|
+
Object.assign(props.state, transformedState.value)
|
|
229
|
+
|
|
230
|
+
return props.state as T
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const loading = ref(false)
|
|
234
|
+
provideFormLoading(readonly(loading))
|
|
235
|
+
|
|
236
|
+
async function onSubmitWrapper(payload: Event) {
|
|
237
|
+
loading.value = true
|
|
238
|
+
|
|
239
|
+
const event = payload as FormSubmitEvent<any>
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
event.data = await validateFn({ nested: true, transform: props.transform })
|
|
243
|
+
await props.onSubmit?.(event)
|
|
244
|
+
dirtyFields.clear()
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (!(error instanceof FormValidationExceptionError))
|
|
248
|
+
throw error
|
|
249
|
+
|
|
250
|
+
const errorEvent: FormErrorEvent = {
|
|
251
|
+
...event,
|
|
252
|
+
errors: error.errors,
|
|
253
|
+
children: error.children,
|
|
254
|
+
}
|
|
255
|
+
emit('error', errorEvent)
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
loading.value = false
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const disabled = computed(() => props.disabled || loading.value)
|
|
263
|
+
|
|
264
|
+
provideFormOptions(computed(() => ({
|
|
265
|
+
disabled: disabled.value,
|
|
266
|
+
validateOnInputDelay: props.validateOnInputDelay,
|
|
267
|
+
})))
|
|
268
|
+
|
|
269
|
+
defineExpose<FormExpose<T>>({
|
|
270
|
+
validate: validateFn as FormExpose<T>['validate'],
|
|
271
|
+
errors,
|
|
272
|
+
|
|
273
|
+
setErrors(errs: FormError[], name?: keyof T) {
|
|
274
|
+
errors.value = name
|
|
275
|
+
? errors.value
|
|
276
|
+
.filter((error) => error.name !== name)
|
|
277
|
+
.concat(resolveErrorIds(errs))
|
|
278
|
+
: resolveErrorIds(errs)
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
async submit() {
|
|
282
|
+
await onSubmitWrapper(new Event('submit'))
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
getErrors(name?: keyof T) {
|
|
286
|
+
if (name)
|
|
287
|
+
return errors.value.filter((err) => err.name === name)
|
|
288
|
+
|
|
289
|
+
return errors.value
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
clear(name?: string) {
|
|
293
|
+
errors.value = name ? errors.value.filter((err) => err.name !== name) : []
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
disabled,
|
|
297
|
+
dirty: computed(() => dirtyFields.size > 0),
|
|
298
|
+
dirtyFields: readonly(dirtyFields) as DeepReadonly<Set<keyof T>>,
|
|
299
|
+
blurredFields: readonly(blurredFields) as DeepReadonly<Set<keyof T>>,
|
|
300
|
+
touchedFields: readonly(touchedFields) as DeepReadonly<Set<keyof T>>,
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const { generateStyle } = useTheme()
|
|
304
|
+
const style = computed(() => generateStyle('form', props))
|
|
305
|
+
</script>
|
|
306
|
+
|
|
307
|
+
<template>
|
|
308
|
+
<component :is="parentBus ? 'div' : 'form'" :id="formId" :class="style" @submit.prevent="onSubmitWrapper">
|
|
309
|
+
<slot :errors="errors"></slot>
|
|
310
|
+
</component>
|
|
311
|
+
</template>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { VariantProps } from '@byyuurin/ui-kit'
|
|
3
|
+
import type { PrimitiveProps } from 'reka-ui'
|
|
4
|
+
import type { formItem } from '../theme'
|
|
5
|
+
import type { ComponentAttrs } from '../types'
|
|
6
|
+
|
|
7
|
+
export interface FormFieldSlots {
|
|
8
|
+
label?: (props: { label?: string }) => any
|
|
9
|
+
hint?: (props: { hint?: string }) => any
|
|
10
|
+
description?: (props: { description?: string }) => any
|
|
11
|
+
help?: (props: { help?: string }) => any
|
|
12
|
+
error?: (props: { error?: string | boolean }) => any
|
|
13
|
+
default?: (props: { error?: string | boolean }) => any
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type FormItemVariants = VariantProps<typeof formItem>
|
|
17
|
+
|
|
18
|
+
export interface FormItemProps extends ComponentAttrs<typeof formItem> {
|
|
19
|
+
/**
|
|
20
|
+
* The element or component this component should render as.
|
|
21
|
+
* @default "div"
|
|
22
|
+
*/
|
|
23
|
+
as?: PrimitiveProps['as']
|
|
24
|
+
/** The name of the FormItem. Also used to match form errors. */
|
|
25
|
+
name?: string
|
|
26
|
+
/** A regular expression to match form error names. */
|
|
27
|
+
errorPattern?: RegExp
|
|
28
|
+
label?: string
|
|
29
|
+
description?: string
|
|
30
|
+
help?: string
|
|
31
|
+
error?: string | boolean
|
|
32
|
+
hint?: string
|
|
33
|
+
/**
|
|
34
|
+
* @default 'md'
|
|
35
|
+
*/
|
|
36
|
+
size?: FormItemVariants['size']
|
|
37
|
+
required?: boolean
|
|
38
|
+
/** If true, validation on input will be active immediately instead of waiting for a blur event. */
|
|
39
|
+
eagerValidation?: boolean
|
|
40
|
+
/**
|
|
41
|
+
* Delay in milliseconds before validating the form on input events.
|
|
42
|
+
* @default 300
|
|
43
|
+
*/
|
|
44
|
+
validateOnInputDelay?: number
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<script setup lang="ts">
|
|
49
|
+
import { Label, Primitive } from 'reka-ui'
|
|
50
|
+
import { computed, ref, useId } from 'vue'
|
|
51
|
+
import { injectFormErrors, provideFormInputId, provideFormItem } from '../app/injections'
|
|
52
|
+
import { useTheme } from '../composables/useTheme'
|
|
53
|
+
|
|
54
|
+
const props = defineProps<FormItemProps>()
|
|
55
|
+
const slots = defineSlots<FormFieldSlots>()
|
|
56
|
+
|
|
57
|
+
const id = ref(useId())
|
|
58
|
+
// Copies id's initial value to bind aria-attributes such as aria-describedby.
|
|
59
|
+
// This is required for the RadioGroup component which unsets the id value.
|
|
60
|
+
const ariaId = id.value
|
|
61
|
+
provideFormInputId(id)
|
|
62
|
+
|
|
63
|
+
const formErrors = injectFormErrors()
|
|
64
|
+
const error = computed(() => {
|
|
65
|
+
if (props.error)
|
|
66
|
+
return props.error
|
|
67
|
+
|
|
68
|
+
const formError = formErrors?.value.find((error) => {
|
|
69
|
+
if (error.name && error.name === props.name)
|
|
70
|
+
return true
|
|
71
|
+
|
|
72
|
+
if (error.name && props.errorPattern)
|
|
73
|
+
return error.name.match(props.errorPattern)
|
|
74
|
+
|
|
75
|
+
return false
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return formError?.message
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
provideFormItem(computed(() => ({
|
|
82
|
+
...props,
|
|
83
|
+
error: error.value,
|
|
84
|
+
ariaId,
|
|
85
|
+
})))
|
|
86
|
+
|
|
87
|
+
const { generateStyle } = useTheme()
|
|
88
|
+
const style = computed(() => generateStyle('formItem', props))
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<Primitive :as="props.as" :class="style.root({ class: [props.class, props.ui?.root] })">
|
|
93
|
+
<div :class="style.wrapper({ class: props.ui?.wrapper })">
|
|
94
|
+
<div v-if="props.label || slots.label" :class="style.labelWrapper({ class: props.ui?.labelWrapper })">
|
|
95
|
+
<Label :for="id" :class="style.label({ class: props.ui?.label })">
|
|
96
|
+
<slot name="label" :label="props.label">
|
|
97
|
+
{{ props.label }}
|
|
98
|
+
</slot>
|
|
99
|
+
</Label>
|
|
100
|
+
<span v-if="props.hint || slots.hint" :id="`${ariaId}-hint`" :class="style.hint({ class: props.ui?.hint })">
|
|
101
|
+
<slot name="hint" :hint="props.hint">
|
|
102
|
+
{{ props.hint }}
|
|
103
|
+
</slot>
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<p v-if="props.description || slots.description" :id="`${ariaId}-description`" :class="style.description({ class: props.ui?.description })">
|
|
108
|
+
<slot name="description" :description="props.description">
|
|
109
|
+
{{ props.description }}
|
|
110
|
+
</slot>
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div :class="(props.label || slots.label || props.description || slots.description) && style.container({ class: props.ui?.container })">
|
|
115
|
+
<slot :error="error"></slot>
|
|
116
|
+
|
|
117
|
+
<p v-if="(typeof error === 'string' && error) || slots.error" :id="`${ariaId}-error`" :class="style.error({ class: props.ui?.error })">
|
|
118
|
+
<slot name="error" :error="error">
|
|
119
|
+
{{ error }}
|
|
120
|
+
</slot>
|
|
121
|
+
</p>
|
|
122
|
+
<p v-else :class="style.help({ class: props.ui?.help })">
|
|
123
|
+
<slot name="help" :help="props.help">
|
|
124
|
+
{{ help }}
|
|
125
|
+
</slot>
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
</Primitive>
|
|
129
|
+
</template>
|