@sakoa/ui 0.1.0
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 +171 -0
- package/dist/App.d.ts +2 -0
- package/dist/cli/index.js +9243 -0
- package/dist/components/DemoSection.d.ts +30 -0
- package/dist/components/SApiKeyboard.d.ts +22 -0
- package/dist/components/SApiSection.d.ts +21 -0
- package/dist/components/SApiTable.d.ts +46 -0
- package/dist/components/STableOfContents.d.ts +2 -0
- package/dist/components/ui/SAlert.d.ts +76 -0
- package/dist/components/ui/SBadge.d.ts +56 -0
- package/dist/components/ui/SButton.d.ts +67 -0
- package/dist/components/ui/SCheckbox.d.ts +64 -0
- package/dist/components/ui/SChip.d.ts +43 -0
- package/dist/components/ui/SDatePicker.d.ts +77 -0
- package/dist/components/ui/SGlassButton.d.ts +70 -0
- package/dist/components/ui/SIcon.d.ts +29 -0
- package/dist/components/ui/SInput.d.ts +129 -0
- package/dist/components/ui/SKbd.d.ts +24 -0
- package/dist/components/ui/SKbdShortcut.d.ts +14 -0
- package/dist/components/ui/SSelect.d.ts +148 -0
- package/dist/components/ui/SSkeleton.d.ts +37 -0
- package/dist/components/ui/SSwitch.d.ts +61 -0
- package/dist/components/ui/STooltip.d.ts +82 -0
- package/dist/components/ui/accordion/SAccordionContent.d.ts +23 -0
- package/dist/components/ui/accordion/SAccordionItem.d.ts +70 -0
- package/dist/components/ui/accordion/SAccordionTrigger.d.ts +37 -0
- package/dist/components/ui/accordion/index.d.ts +4 -0
- package/dist/components/ui/avatar/SAvatar.d.ts +36 -0
- package/dist/components/ui/avatar/SAvatarFallback.d.ts +26 -0
- package/dist/components/ui/avatar/SAvatarGroup.d.ts +30 -0
- package/dist/components/ui/avatar/SAvatarImage.d.ts +23 -0
- package/dist/components/ui/avatar/index.d.ts +4 -0
- package/dist/components/ui/breadcrumb/SBreadcrumb.d.ts +22 -0
- package/dist/components/ui/breadcrumb/SBreadcrumbEllipsis.d.ts +17 -0
- package/dist/components/ui/breadcrumb/SBreadcrumbItem.d.ts +17 -0
- package/dist/components/ui/breadcrumb/SBreadcrumbLink.d.ts +26 -0
- package/dist/components/ui/breadcrumb/SBreadcrumbList.d.ts +17 -0
- package/dist/components/ui/breadcrumb/SBreadcrumbPage.d.ts +17 -0
- package/dist/components/ui/breadcrumb/SBreadcrumbSeparator.d.ts +17 -0
- package/dist/components/ui/breadcrumb/index.d.ts +7 -0
- package/dist/components/ui/card/SCard.d.ts +103 -0
- package/dist/components/ui/card/SCardActions.d.ts +44 -0
- package/dist/components/ui/card/SCardContent.d.ts +35 -0
- package/dist/components/ui/card/SCardFooter.d.ts +38 -0
- package/dist/components/ui/card/SCardHeader.d.ts +53 -0
- package/dist/components/ui/card/SCardMedia.d.ts +83 -0
- package/dist/components/ui/card/SGlassCard.d.ts +103 -0
- package/dist/components/ui/card/SMorphingCardContent.d.ts +18 -0
- package/dist/components/ui/card/index.d.ts +24 -0
- package/dist/components/ui/carousel/SCarousel.d.ts +166 -0
- package/dist/components/ui/carousel/index.d.ts +2 -0
- package/dist/components/ui/color-picker/SColorPickerAlphaSlider.d.ts +4 -0
- package/dist/components/ui/color-picker/SColorPickerCopy.d.ts +19 -0
- package/dist/components/ui/color-picker/SColorPickerEyeDropper.d.ts +17 -0
- package/dist/components/ui/color-picker/SColorPickerHueSlider.d.ts +4 -0
- package/dist/components/ui/color-picker/SColorPickerInputs.d.ts +2 -0
- package/dist/components/ui/color-picker/SColorPickerPresets.d.ts +9 -0
- package/dist/components/ui/color-picker/SColorPickerPreview.d.ts +2 -0
- package/dist/components/ui/color-picker/SColorPickerRecent.d.ts +7 -0
- package/dist/components/ui/color-picker/SColorPickerSpectrum.d.ts +4 -0
- package/dist/components/ui/color-picker/index.d.ts +11 -0
- package/dist/components/ui/drawer/index.d.ts +11 -0
- package/dist/components/ui/dropdown/SDropdownDivider.d.ts +8 -0
- package/dist/components/ui/dropdown/SDropdownGroup.d.ts +25 -0
- package/dist/components/ui/dropdown/SDropdownItem.d.ts +56 -0
- package/dist/components/ui/dropdown/index.d.ts +4 -0
- package/dist/components/ui/form/SForm.d.ts +38 -0
- package/dist/components/ui/form/SFormField.d.ts +31 -0
- package/dist/components/ui/form/index.d.ts +5 -0
- package/dist/components/ui/modal/index.d.ts +19 -0
- package/dist/components/ui/option/SOption.d.ts +32 -0
- package/dist/components/ui/option/SOptionGroup.d.ts +28 -0
- package/dist/components/ui/option/index.d.ts +2 -0
- package/dist/components/ui/otp/SOTP.d.ts +122 -0
- package/dist/components/ui/otp/SOTPGroup.d.ts +23 -0
- package/dist/components/ui/otp/SOTPSeparator.d.ts +17 -0
- package/dist/components/ui/otp/SOTPSlot.d.ts +49 -0
- package/dist/components/ui/otp/index.d.ts +7 -0
- package/dist/components/ui/otp/types.d.ts +26 -0
- package/dist/components/ui/otp/useOTPContext.d.ts +42 -0
- package/dist/components/ui/pagination/SPagination.d.ts +151 -0
- package/dist/components/ui/pagination/index.d.ts +2 -0
- package/dist/components/ui/progress/SProgress.d.ts +62 -0
- package/dist/components/ui/progress/SProgressRange.d.ts +91 -0
- package/dist/components/ui/progress/index.d.ts +4 -0
- package/dist/components/ui/radio/SRadio.d.ts +58 -0
- package/dist/components/ui/radio/SRadioGroup.d.ts +52 -0
- package/dist/components/ui/radio/index.d.ts +2 -0
- package/dist/components/ui/stepper/SStepper.d.ts +83 -0
- package/dist/components/ui/stepper/SStepperContent.d.ts +24 -0
- package/dist/components/ui/stepper/SStepperDescription.d.ts +20 -0
- package/dist/components/ui/stepper/SStepperIndicator.d.ts +37 -0
- package/dist/components/ui/stepper/SStepperItem.d.ts +37 -0
- package/dist/components/ui/stepper/SStepperSeparator.d.ts +5 -0
- package/dist/components/ui/stepper/SStepperTitle.d.ts +20 -0
- package/dist/components/ui/stepper/SStepperTrigger.d.ts +22 -0
- package/dist/components/ui/stepper/index.d.ts +11 -0
- package/dist/components/ui/table/STableBody.d.ts +27 -0
- package/dist/components/ui/table/STableCell.d.ts +55 -0
- package/dist/components/ui/table/STableColumn.d.ts +87 -0
- package/dist/components/ui/table/STableEmpty.d.ts +54 -0
- package/dist/components/ui/table/STableHeader.d.ts +25 -0
- package/dist/components/ui/table/STableRow.d.ts +38 -0
- package/dist/components/ui/table/STableSkeleton.d.ts +29 -0
- package/dist/components/ui/table/index.d.ts +98 -0
- package/dist/components/ui/table/useDataTable.d.ts +80 -0
- package/dist/components/ui/tabs/STabPane.d.ts +31 -0
- package/dist/components/ui/tabs/STabsContent.d.ts +21 -0
- package/dist/components/ui/tabs/STabsIndicator.d.ts +9 -0
- package/dist/components/ui/tabs/STabsTrigger.d.ts +28 -0
- package/dist/components/ui/tabs/index.d.ts +6 -0
- package/dist/components/ui/toast/SToast.d.ts +49 -0
- package/dist/components/ui/toast/SToastContainer.d.ts +21 -0
- package/dist/components/ui/toast/index.d.ts +2 -0
- package/dist/composables/useAsync.d.ts +134 -0
- package/dist/composables/useClickOutside.d.ts +69 -0
- package/dist/composables/useClipboard.d.ts +46 -0
- package/dist/composables/useDebounce.d.ts +150 -0
- package/dist/composables/useDialog.d.ts +118 -0
- package/dist/composables/useForm.d.ts +204 -0
- package/dist/composables/useHotkey.d.ts +128 -0
- package/dist/composables/useIntersectionObserver.d.ts +156 -0
- package/dist/composables/useLocalStorage.d.ts +120 -0
- package/dist/composables/useMediaQuery.d.ts +115 -0
- package/dist/composables/useTheme.d.ts +8 -0
- package/dist/composables/useToast.d.ts +1619 -0
- package/dist/index.d.ts +71 -0
- package/dist/layouts/UILayout.d.ts +2 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/main.d.ts +0 -0
- package/dist/router.d.ts +2 -0
- package/dist/saka-ui.css +1 -0
- package/dist/saka-ui.js +18513 -0
- package/dist/saka-ui.umd.cjs +38 -0
- package/dist/views/docs/CustomizationView.d.ts +2 -0
- package/dist/views/docs/FormValidationView.d.ts +2 -0
- package/dist/views/docs/StylingGuideView.d.ts +2 -0
- package/dist/views/docs/UseAsyncView.d.ts +2 -0
- package/dist/views/docs/UseClickOutsideView.d.ts +124 -0
- package/dist/views/docs/UseClipboardView.d.ts +4 -0
- package/dist/views/docs/UseDebounceView.d.ts +2 -0
- package/dist/views/docs/UseHotkeyView.d.ts +205 -0
- package/dist/views/docs/UseIntersectionObserverView.d.ts +5 -0
- package/dist/views/docs/UseLocalStorageView.d.ts +2 -0
- package/dist/views/docs/UseMediaQueryView.d.ts +2 -0
- package/dist/views/docs/UseThemeView.d.ts +2 -0
- package/dist/views/examples/AuthFormView.d.ts +2 -0
- package/dist/views/examples/CreditCardFormView.d.ts +6 -0
- package/dist/views/examples/FormFieldExampleView.d.ts +2 -0
- package/dist/views/examples/ProjectFormView.d.ts +2 -0
- package/dist/views/ui/AccordionView.d.ts +2 -0
- package/dist/views/ui/AlertView.d.ts +2 -0
- package/dist/views/ui/AvatarView.d.ts +2 -0
- package/dist/views/ui/BadgeView.d.ts +2 -0
- package/dist/views/ui/BreadcrumbView.d.ts +2 -0
- package/dist/views/ui/ButtonView.d.ts +2 -0
- package/dist/views/ui/CardView.d.ts +2 -0
- package/dist/views/ui/CarouselView.d.ts +274 -0
- package/dist/views/ui/CheckboxView.d.ts +2 -0
- package/dist/views/ui/ChipView.d.ts +2 -0
- package/dist/views/ui/ColorPickerView.d.ts +2 -0
- package/dist/views/ui/DatePickerView.d.ts +2 -0
- package/dist/views/ui/DialogView.d.ts +2 -0
- package/dist/views/ui/DrawerView.d.ts +2 -0
- package/dist/views/ui/DropdownView.d.ts +2 -0
- package/dist/views/ui/GlassButtonView.d.ts +2 -0
- package/dist/views/ui/GlassCardView.d.ts +2 -0
- package/dist/views/ui/HomeView.d.ts +2 -0
- package/dist/views/ui/IconsView.d.ts +2 -0
- package/dist/views/ui/InputView.d.ts +2 -0
- package/dist/views/ui/KbdView.d.ts +2 -0
- package/dist/views/ui/ModalView.d.ts +2 -0
- package/dist/views/ui/MorphingCardView.d.ts +2 -0
- package/dist/views/ui/MorphingModalView.d.ts +2 -0
- package/dist/views/ui/OTPView.d.ts +206 -0
- package/dist/views/ui/PaginationView.d.ts +2 -0
- package/dist/views/ui/ProgressView.d.ts +2 -0
- package/dist/views/ui/RadioView.d.ts +2 -0
- package/dist/views/ui/SelectView.d.ts +2 -0
- package/dist/views/ui/SkeletonView.d.ts +2 -0
- package/dist/views/ui/StepperView.d.ts +2 -0
- package/dist/views/ui/SwitchView.d.ts +2 -0
- package/dist/views/ui/TableView.d.ts +2 -0
- package/dist/views/ui/TabsView.d.ts +2 -0
- package/dist/views/ui/ToastView.d.ts +2 -0
- package/dist/views/ui/TooltipView.d.ts +2 -0
- package/dist/vite.svg +1 -0
- package/package.json +64 -0
- package/registry/components/accordion.json +19 -0
- package/registry/components/alert.json +17 -0
- package/registry/components/avatar.json +18 -0
- package/registry/components/badge.json +14 -0
- package/registry/components/breadcrumb.json +24 -0
- package/registry/components/button.json +17 -0
- package/registry/components/card.json +23 -0
- package/registry/components/carousel.json +19 -0
- package/registry/components/checkbox.json +17 -0
- package/registry/components/chip.json +17 -0
- package/registry/components/color-picker.json +24 -0
- package/registry/components/date-picker.json +17 -0
- package/registry/components/drawer.json +26 -0
- package/registry/components/dropdown.json +21 -0
- package/registry/components/form.json +16 -0
- package/registry/components/glass-button.json +17 -0
- package/registry/components/icon.json +17 -0
- package/registry/components/input.json +17 -0
- package/registry/components/kbd.json +16 -0
- package/registry/components/modal.json +32 -0
- package/registry/components/option.json +16 -0
- package/registry/components/otp.json +23 -0
- package/registry/components/pagination.json +18 -0
- package/registry/components/progress.json +16 -0
- package/registry/components/radio.json +19 -0
- package/registry/components/select.json +17 -0
- package/registry/components/skeleton.json +14 -0
- package/registry/components/switch.json +17 -0
- package/registry/components/table.json +26 -0
- package/registry/components/tabs.json +19 -0
- package/registry/components/toast.json +19 -0
- package/registry/components/tooltip.json +14 -0
- package/registry/index.json +4 -0
- package/registry/source/components/ui/SAlert.vue +388 -0
- package/registry/source/components/ui/SBadge.vue +243 -0
- package/registry/source/components/ui/SButton.vue +387 -0
- package/registry/source/components/ui/SCheckbox.vue +310 -0
- package/registry/source/components/ui/SChip.vue +130 -0
- package/registry/source/components/ui/SDatePicker.vue +1290 -0
- package/registry/source/components/ui/SGlassButton.vue +547 -0
- package/registry/source/components/ui/SIcon.vue +78 -0
- package/registry/source/components/ui/SInput.vue +1054 -0
- package/registry/source/components/ui/SKbd.vue +96 -0
- package/registry/source/components/ui/SKbdShortcut.vue +36 -0
- package/registry/source/components/ui/SSelect.vue +1290 -0
- package/registry/source/components/ui/SSkeleton.vue +185 -0
- package/registry/source/components/ui/SSwitch.vue +275 -0
- package/registry/source/components/ui/STooltip.vue +491 -0
- package/registry/source/components/ui/accordion/SAccordion.vue +248 -0
- package/registry/source/components/ui/accordion/SAccordionItem.vue +353 -0
- package/registry/source/components/ui/accordion/index.ts +5 -0
- package/registry/source/components/ui/avatar/SAvatar.vue +169 -0
- package/registry/source/components/ui/avatar/SAvatarFallback.vue +66 -0
- package/registry/source/components/ui/avatar/SAvatarGroup.vue +69 -0
- package/registry/source/components/ui/avatar/SAvatarImage.vue +92 -0
- package/registry/source/components/ui/avatar/index.ts +5 -0
- package/registry/source/components/ui/breadcrumb/SBreadcrumb.vue +23 -0
- package/registry/source/components/ui/breadcrumb/SBreadcrumbEllipsis.vue +17 -0
- package/registry/source/components/ui/breadcrumb/SBreadcrumbItem.vue +14 -0
- package/registry/source/components/ui/breadcrumb/SBreadcrumbLink.vue +46 -0
- package/registry/source/components/ui/breadcrumb/SBreadcrumbList.vue +17 -0
- package/registry/source/components/ui/breadcrumb/SBreadcrumbPage.vue +15 -0
- package/registry/source/components/ui/breadcrumb/SBreadcrumbSeparator.vue +18 -0
- package/registry/source/components/ui/breadcrumb/index.ts +7 -0
- package/registry/source/components/ui/card/SCard.vue +517 -0
- package/registry/source/components/ui/card/SCardActions.vue +129 -0
- package/registry/source/components/ui/card/SCardContent.vue +117 -0
- package/registry/source/components/ui/card/SCardFooter.vue +103 -0
- package/registry/source/components/ui/card/SCardHeader.vue +163 -0
- package/registry/source/components/ui/card/SCardMedia.vue +312 -0
- package/registry/source/components/ui/card/index.ts +34 -0
- package/registry/source/components/ui/carousel/SCarousel.vue +1069 -0
- package/registry/source/components/ui/carousel/SCarouselSlide.vue +107 -0
- package/registry/source/components/ui/carousel/index.ts +3 -0
- package/registry/source/components/ui/color-picker/SColorPicker.vue +772 -0
- package/registry/source/components/ui/color-picker/SColorPickerAlphaSlider.vue +158 -0
- package/registry/source/components/ui/color-picker/SColorPickerCopy.vue +76 -0
- package/registry/source/components/ui/color-picker/SColorPickerEyeDropper.vue +68 -0
- package/registry/source/components/ui/color-picker/SColorPickerHueSlider.vue +138 -0
- package/registry/source/components/ui/color-picker/SColorPickerInputs.vue +227 -0
- package/registry/source/components/ui/color-picker/SColorPickerPresets.vue +87 -0
- package/registry/source/components/ui/color-picker/SColorPickerPreview.vue +46 -0
- package/registry/source/components/ui/color-picker/SColorPickerRecent.vue +74 -0
- package/registry/source/components/ui/color-picker/SColorPickerSpectrum.vue +149 -0
- package/registry/source/components/ui/color-picker/index.ts +11 -0
- package/registry/source/components/ui/drawer/SDrawer.vue +797 -0
- package/registry/source/components/ui/drawer/SDrawerClose.vue +64 -0
- package/registry/source/components/ui/drawer/SDrawerContent.vue +81 -0
- package/registry/source/components/ui/drawer/SDrawerDescription.vue +40 -0
- package/registry/source/components/ui/drawer/SDrawerFooter.vue +97 -0
- package/registry/source/components/ui/drawer/SDrawerHandle.vue +79 -0
- package/registry/source/components/ui/drawer/SDrawerHeader.vue +117 -0
- package/registry/source/components/ui/drawer/SDrawerTitle.vue +40 -0
- package/registry/source/components/ui/drawer/SDrawerTrigger.vue +51 -0
- package/registry/source/components/ui/drawer/index.ts +20 -0
- package/registry/source/components/ui/dropdown/SDropdown.vue +843 -0
- package/registry/source/components/ui/dropdown/SDropdownDivider.vue +23 -0
- package/registry/source/components/ui/dropdown/SDropdownGroup.vue +53 -0
- package/registry/source/components/ui/dropdown/SDropdownItem.vue +179 -0
- package/registry/source/components/ui/dropdown/index.ts +5 -0
- package/registry/source/components/ui/form/SForm.vue +84 -0
- package/registry/source/components/ui/form/SFormField.vue +78 -0
- package/registry/source/components/ui/form/index.ts +8 -0
- package/registry/source/components/ui/modal/SModal.vue +648 -0
- package/registry/source/components/ui/modal/SModalClose.vue +49 -0
- package/registry/source/components/ui/modal/SModalContent.vue +74 -0
- package/registry/source/components/ui/modal/SModalDescription.vue +39 -0
- package/registry/source/components/ui/modal/SModalFooter.vue +84 -0
- package/registry/source/components/ui/modal/SModalHeader.vue +107 -0
- package/registry/source/components/ui/modal/SModalTitle.vue +39 -0
- package/registry/source/components/ui/modal/SModalTrigger.vue +61 -0
- package/registry/source/components/ui/modal/SMorphingModal.vue +429 -0
- package/registry/source/components/ui/modal/SMorphingModalClose.vue +42 -0
- package/registry/source/components/ui/modal/SMorphingModalDescription.vue +49 -0
- package/registry/source/components/ui/modal/SMorphingModalImage.vue +44 -0
- package/registry/source/components/ui/modal/SMorphingModalSubtitle.vue +29 -0
- package/registry/source/components/ui/modal/SMorphingModalTitle.vue +34 -0
- package/registry/source/components/ui/modal/SMorphingModalTrigger.vue +95 -0
- package/registry/source/components/ui/modal/index.ts +32 -0
- package/registry/source/components/ui/option/SOption.vue +180 -0
- package/registry/source/components/ui/option/SOptionGroup.vue +77 -0
- package/registry/source/components/ui/option/index.ts +3 -0
- package/registry/source/components/ui/otp/SOTP.vue +843 -0
- package/registry/source/components/ui/otp/SOTPGroup.vue +29 -0
- package/registry/source/components/ui/otp/SOTPSeparator.vue +15 -0
- package/registry/source/components/ui/otp/SOTPSlot.vue +462 -0
- package/registry/source/components/ui/otp/index.ts +7 -0
- package/registry/source/components/ui/otp/types.ts +27 -0
- package/registry/source/components/ui/otp/useOTPContext.ts +62 -0
- package/registry/source/components/ui/pagination/SPagination.vue +923 -0
- package/registry/source/components/ui/pagination/index.ts +8 -0
- package/registry/source/components/ui/progress/SProgress.vue +635 -0
- package/registry/source/components/ui/progress/SProgressRange.vue +715 -0
- package/registry/source/components/ui/progress/index.ts +4 -0
- package/registry/source/components/ui/radio/SRadio.vue +407 -0
- package/registry/source/components/ui/radio/SRadioGroup.vue +200 -0
- package/registry/source/components/ui/radio/index.ts +3 -0
- package/registry/source/components/ui/table/SDataTable.vue +828 -0
- package/registry/source/components/ui/table/STableBody.vue +70 -0
- package/registry/source/components/ui/table/STableCell.vue +147 -0
- package/registry/source/components/ui/table/STableColumn.vue +120 -0
- package/registry/source/components/ui/table/STableEmpty.vue +159 -0
- package/registry/source/components/ui/table/STableHeader.vue +132 -0
- package/registry/source/components/ui/table/STableRow.vue +106 -0
- package/registry/source/components/ui/table/STableSkeleton.vue +208 -0
- package/registry/source/components/ui/table/index.ts +126 -0
- package/registry/source/components/ui/table/useDataTable.ts +519 -0
- package/registry/source/components/ui/tabs/STabPane.vue +130 -0
- package/registry/source/components/ui/tabs/STabs.vue +467 -0
- package/registry/source/components/ui/tabs/index.ts +7 -0
- package/registry/source/components/ui/toast/SToast.vue +261 -0
- package/registry/source/components/ui/toast/SToastContainer.vue +209 -0
- package/registry/source/components/ui/toast/index.ts +2 -0
- package/registry/source/composables/useForm.ts +960 -0
- package/registry/source/composables/useTheme.ts +86 -0
- package/registry/source/composables/useToast.ts +440 -0
- package/registry/source/lib/utils.ts +6 -0
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
import { ref, computed, watch, onMounted, type Ref, type ComputedRef, type WatchStopHandle } from 'vue'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export type ValidationRule<T = unknown> = (value: T, formData?: Record<string, unknown>) => string | true
|
|
8
|
+
export type AsyncValidationRule<T = unknown> = (value: T, formData?: Record<string, unknown>) => Promise<string | true>
|
|
9
|
+
|
|
10
|
+
export interface FieldConfig<T = unknown> {
|
|
11
|
+
rules?: (ValidationRule<T> | AsyncValidationRule<T>)[]
|
|
12
|
+
validateOn?: 'input' | 'blur' | 'submit'
|
|
13
|
+
/** Debounce delay in ms for async validations (default: 300) */
|
|
14
|
+
debounce?: number
|
|
15
|
+
/** Field dependencies - revalidate this field when these fields change */
|
|
16
|
+
deps?: string[]
|
|
17
|
+
/** Whether to persist this field (default: true). Set to false to exclude from persistence */
|
|
18
|
+
persist?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FieldState<T = unknown> {
|
|
22
|
+
value: Ref<T>
|
|
23
|
+
error: Ref<string>
|
|
24
|
+
touched: Ref<boolean>
|
|
25
|
+
dirty: Ref<boolean>
|
|
26
|
+
valid: ComputedRef<boolean>
|
|
27
|
+
validate: () => Promise<boolean>
|
|
28
|
+
reset: () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mapped type for type-safe field access */
|
|
32
|
+
export type TypedFields<TValues extends Record<string, unknown>> = {
|
|
33
|
+
[K in keyof TValues]: FieldState<TValues[K]>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Persistence options for form state */
|
|
37
|
+
export interface PersistOptions {
|
|
38
|
+
/** Storage key for the form data */
|
|
39
|
+
key: string
|
|
40
|
+
/** Storage type: 'localStorage' or 'sessionStorage' (default: 'localStorage') */
|
|
41
|
+
storage?: 'localStorage' | 'sessionStorage'
|
|
42
|
+
/** Debounce delay for saving in ms (default: 500) */
|
|
43
|
+
debounce?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Zod-like schema interface for type inference */
|
|
47
|
+
export interface ZodLikeSchema<T = unknown> {
|
|
48
|
+
safeParse: (data: unknown) => { success: true; data: T } | { success: false; error: { errors: Array<{ path: (string | number)[]; message: string }> } }
|
|
49
|
+
shape?: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Form state returned by useForm composable */
|
|
53
|
+
export interface FormState<TValues extends Record<string, unknown> = Record<string, unknown>> {
|
|
54
|
+
/** Type-safe field access */
|
|
55
|
+
fields: TypedFields<TValues>
|
|
56
|
+
errors: ComputedRef<Record<string, string>>
|
|
57
|
+
valid: ComputedRef<boolean>
|
|
58
|
+
dirty: ComputedRef<boolean>
|
|
59
|
+
touched: ComputedRef<boolean>
|
|
60
|
+
validate: () => Promise<boolean>
|
|
61
|
+
reset: (values?: Partial<TValues>) => void
|
|
62
|
+
getValues: () => TValues
|
|
63
|
+
// Submission state
|
|
64
|
+
isSubmitting: Ref<boolean>
|
|
65
|
+
submitError: Ref<string | null>
|
|
66
|
+
submitCount: Ref<number>
|
|
67
|
+
isSubmitSuccessful: Ref<boolean>
|
|
68
|
+
// Value management
|
|
69
|
+
setValue: <K extends keyof TValues>(name: K, value: TValues[K]) => void
|
|
70
|
+
setValues: (values: Partial<TValues>) => void
|
|
71
|
+
getFieldValue: <K extends keyof TValues>(name: K) => TValues[K]
|
|
72
|
+
// Error management
|
|
73
|
+
setError: (name: keyof TValues, error: string) => void
|
|
74
|
+
clearError: (name: keyof TValues) => void
|
|
75
|
+
clearErrors: () => void
|
|
76
|
+
// Field state management
|
|
77
|
+
resetField: (name: keyof TValues) => void
|
|
78
|
+
setFieldTouched: (name: keyof TValues, touched?: boolean) => void
|
|
79
|
+
setFieldDirty: (name: keyof TValues, dirty?: boolean) => void
|
|
80
|
+
// Submission
|
|
81
|
+
handleSubmit: <TResult = void>(
|
|
82
|
+
onSubmit: (values: TValues) => Promise<TResult> | TResult,
|
|
83
|
+
onError?: (error: unknown) => void
|
|
84
|
+
) => () => Promise<TResult | undefined>
|
|
85
|
+
// Watchers
|
|
86
|
+
watchForm: (callback: (values: TValues) => void) => WatchStopHandle
|
|
87
|
+
watchField: <K extends keyof TValues>(name: K, callback: (value: TValues[K], oldValue: TValues[K]) => void) => WatchStopHandle
|
|
88
|
+
// Persistence
|
|
89
|
+
clearPersisted: () => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Options for useForm composable */
|
|
93
|
+
export interface UseFormOptions<TValues extends Record<string, unknown> = Record<string, unknown>> {
|
|
94
|
+
/** Initial values for form fields */
|
|
95
|
+
initialValues?: Partial<TValues>
|
|
96
|
+
/** Field configurations with validation rules */
|
|
97
|
+
fields?: { [K in keyof TValues]?: FieldConfig<TValues[K]> }
|
|
98
|
+
/** Zod schema for validation (alternative to fields.rules) */
|
|
99
|
+
schema?: ZodLikeSchema<TValues>
|
|
100
|
+
/** Global debounce delay for async validations (default: 300ms) */
|
|
101
|
+
debounceDelay?: number
|
|
102
|
+
/** Validation mode: 'onChange' | 'onBlur' | 'onSubmit' | 'all' (default: 'onChange') */
|
|
103
|
+
mode?: 'onChange' | 'onBlur' | 'onSubmit' | 'all'
|
|
104
|
+
/** Persistence options for auto-saving form state */
|
|
105
|
+
persist?: PersistOptions
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// Built-in Validators
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
/** Required field validator */
|
|
113
|
+
export const required = (msg = 'This field is required'): ValidationRule =>
|
|
114
|
+
(value) => {
|
|
115
|
+
if (value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)) {
|
|
116
|
+
return msg
|
|
117
|
+
}
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Minimum length validator */
|
|
122
|
+
export const minLength = (min: number, msg?: string): ValidationRule =>
|
|
123
|
+
(value) => {
|
|
124
|
+
const str = String(value ?? '')
|
|
125
|
+
if (str.length < min) {
|
|
126
|
+
return msg ?? `Minimum ${min} characters`
|
|
127
|
+
}
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Maximum length validator */
|
|
132
|
+
export const maxLength = (max: number, msg?: string): ValidationRule =>
|
|
133
|
+
(value) => {
|
|
134
|
+
const str = String(value ?? '')
|
|
135
|
+
if (str.length > max) {
|
|
136
|
+
return msg ?? `Maximum ${max} characters`
|
|
137
|
+
}
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Minimum value validator (for numbers) */
|
|
142
|
+
export const min = (minVal: number, msg?: string): ValidationRule =>
|
|
143
|
+
(value) => {
|
|
144
|
+
const num = Number(value)
|
|
145
|
+
if (isNaN(num) || num < minVal) {
|
|
146
|
+
return msg ?? `Minimum value is ${minVal}`
|
|
147
|
+
}
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Maximum value validator (for numbers) */
|
|
152
|
+
export const max = (maxVal: number, msg?: string): ValidationRule =>
|
|
153
|
+
(value) => {
|
|
154
|
+
const num = Number(value)
|
|
155
|
+
if (isNaN(num) || num > maxVal) {
|
|
156
|
+
return msg ?? `Maximum value is ${maxVal}`
|
|
157
|
+
}
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Email format validator */
|
|
162
|
+
export const email = (msg = 'Invalid email address'): ValidationRule =>
|
|
163
|
+
(value) => {
|
|
164
|
+
const str = String(value ?? '')
|
|
165
|
+
if (!str) return true // Skip if empty (use required for required fields)
|
|
166
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str)) {
|
|
167
|
+
return msg
|
|
168
|
+
}
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** URL format validator */
|
|
173
|
+
export const url = (msg = 'Invalid URL'): ValidationRule =>
|
|
174
|
+
(value) => {
|
|
175
|
+
const str = String(value ?? '')
|
|
176
|
+
if (!str) return true
|
|
177
|
+
try {
|
|
178
|
+
new URL(str)
|
|
179
|
+
return true
|
|
180
|
+
} catch {
|
|
181
|
+
return msg
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Regex pattern validator */
|
|
186
|
+
export const pattern = (regex: RegExp, msg = 'Invalid format'): ValidationRule =>
|
|
187
|
+
(value) => {
|
|
188
|
+
const str = String(value ?? '')
|
|
189
|
+
if (!str) return true
|
|
190
|
+
if (!regex.test(str)) {
|
|
191
|
+
return msg
|
|
192
|
+
}
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Match another field validator */
|
|
197
|
+
export const sameAs = (fieldName: string, msg?: string): ValidationRule =>
|
|
198
|
+
(value, formData) => {
|
|
199
|
+
if (!formData) return true
|
|
200
|
+
if (value !== formData[fieldName]) {
|
|
201
|
+
return msg ?? `Must match ${fieldName}`
|
|
202
|
+
}
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Contain uppercase letter */
|
|
207
|
+
export const hasUppercase = (msg = 'Must contain an uppercase letter'): ValidationRule =>
|
|
208
|
+
(value) => {
|
|
209
|
+
const str = String(value ?? '')
|
|
210
|
+
if (!str) return true
|
|
211
|
+
if (!/[A-Z]/.test(str)) return msg
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Contain lowercase letter */
|
|
216
|
+
export const hasLowercase = (msg = 'Must contain a lowercase letter'): ValidationRule =>
|
|
217
|
+
(value) => {
|
|
218
|
+
const str = String(value ?? '')
|
|
219
|
+
if (!str) return true
|
|
220
|
+
if (!/[a-z]/.test(str)) return msg
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Contain digit */
|
|
225
|
+
export const hasDigit = (msg = 'Must contain a number'): ValidationRule =>
|
|
226
|
+
(value) => {
|
|
227
|
+
const str = String(value ?? '')
|
|
228
|
+
if (!str) return true
|
|
229
|
+
if (!/\d/.test(str)) return msg
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Contain special character */
|
|
234
|
+
export const hasSpecial = (msg = 'Must contain a special character'): ValidationRule =>
|
|
235
|
+
(value) => {
|
|
236
|
+
const str = String(value ?? '')
|
|
237
|
+
if (!str) return true
|
|
238
|
+
if (!/[!@#$%^&*(),.?":{}|<>]/.test(str)) return msg
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Alphanumeric only */
|
|
243
|
+
export const alphanumeric = (msg = 'Only letters and numbers allowed'): ValidationRule =>
|
|
244
|
+
(value) => {
|
|
245
|
+
const str = String(value ?? '')
|
|
246
|
+
if (!str) return true
|
|
247
|
+
if (!/^[a-zA-Z0-9]+$/.test(str)) return msg
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Numeric only */
|
|
252
|
+
export const numeric = (msg = 'Only numbers allowed'): ValidationRule =>
|
|
253
|
+
(value) => {
|
|
254
|
+
const str = String(value ?? '')
|
|
255
|
+
if (!str) return true
|
|
256
|
+
if (!/^\d+$/.test(str)) return msg
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ============================================================================
|
|
261
|
+
// Custom Validators
|
|
262
|
+
// ============================================================================
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Create a custom validator from a boolean function
|
|
266
|
+
* @example custom((v) => v.length > 5, 'Too short')
|
|
267
|
+
* @example custom(async (v) => await checkUsername(v), 'Username taken')
|
|
268
|
+
*/
|
|
269
|
+
export const custom = (
|
|
270
|
+
fn: (value: unknown, formData?: Record<string, unknown>) => boolean | Promise<boolean>,
|
|
271
|
+
msg = 'Validation failed'
|
|
272
|
+
): ValidationRule | AsyncValidationRule =>
|
|
273
|
+
async (value, formData) => {
|
|
274
|
+
const result = await fn(value, formData)
|
|
275
|
+
return result ? true : msg
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Password strength levels */
|
|
279
|
+
export enum PasswordStrength {
|
|
280
|
+
/** 6+ chars */
|
|
281
|
+
WEAK = 'weak',
|
|
282
|
+
/** 8+ chars, 1 uppercase, 1 number */
|
|
283
|
+
MEDIUM = 'medium',
|
|
284
|
+
/** 10+ chars, 1 uppercase, 1 lowercase, 1 number, 1 special */
|
|
285
|
+
STRONG = 'strong',
|
|
286
|
+
/** 12+ chars, 2 uppercase, 2 lowercase, 2 numbers, 1 special */
|
|
287
|
+
VERY_STRONG = 'very_strong'
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Password strength validator with configurable levels */
|
|
291
|
+
export const passwordStrength = (
|
|
292
|
+
strength: PasswordStrength = PasswordStrength.MEDIUM,
|
|
293
|
+
msg?: string
|
|
294
|
+
): ValidationRule =>
|
|
295
|
+
(value) => {
|
|
296
|
+
const str = String(value ?? '')
|
|
297
|
+
if (!str) return true // Use required() for required check
|
|
298
|
+
|
|
299
|
+
const checks = {
|
|
300
|
+
[PasswordStrength.WEAK]: {
|
|
301
|
+
minLength: 6,
|
|
302
|
+
test: () => true,
|
|
303
|
+
defaultMsg: 'Password must be at least 6 characters'
|
|
304
|
+
},
|
|
305
|
+
[PasswordStrength.MEDIUM]: {
|
|
306
|
+
minLength: 8,
|
|
307
|
+
test: () => /[A-Z]/.test(str) && /\d/.test(str),
|
|
308
|
+
defaultMsg: 'Password needs 8+ chars, 1 uppercase, 1 number'
|
|
309
|
+
},
|
|
310
|
+
[PasswordStrength.STRONG]: {
|
|
311
|
+
minLength: 10,
|
|
312
|
+
test: () =>
|
|
313
|
+
/[A-Z]/.test(str) &&
|
|
314
|
+
/[a-z]/.test(str) &&
|
|
315
|
+
/\d/.test(str) &&
|
|
316
|
+
/[!@#$%^&*(),.?":{}|<>]/.test(str),
|
|
317
|
+
defaultMsg: 'Password needs 10+ chars, upper, lower, number, special'
|
|
318
|
+
},
|
|
319
|
+
[PasswordStrength.VERY_STRONG]: {
|
|
320
|
+
minLength: 12,
|
|
321
|
+
test: () => {
|
|
322
|
+
const uppers = (str.match(/[A-Z]/g) || []).length >= 2
|
|
323
|
+
const lowers = (str.match(/[a-z]/g) || []).length >= 2
|
|
324
|
+
const digits = (str.match(/\d/g) || []).length >= 2
|
|
325
|
+
const special = /[!@#$%^&*(),.?":{}|<>]/.test(str)
|
|
326
|
+
return uppers && lowers && digits && special
|
|
327
|
+
},
|
|
328
|
+
defaultMsg: 'Password needs 12+ chars, 2 upper, 2 lower, 2 numbers, 1 special'
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const check = checks[strength]
|
|
333
|
+
if (str.length < check.minLength) {
|
|
334
|
+
return msg || check.defaultMsg
|
|
335
|
+
}
|
|
336
|
+
if (!check.test()) {
|
|
337
|
+
return msg || check.defaultMsg
|
|
338
|
+
}
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Check if value is different from another field */
|
|
343
|
+
export const different = (fieldName: string, msg?: string): ValidationRule =>
|
|
344
|
+
(value, formData) => {
|
|
345
|
+
if (!formData) return true
|
|
346
|
+
if (value === formData[fieldName]) {
|
|
347
|
+
return msg ?? `Must be different from ${fieldName}`
|
|
348
|
+
}
|
|
349
|
+
return true
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Check if value is in a list of allowed values */
|
|
353
|
+
export const oneOf = <T>(allowed: T[], msg?: string): ValidationRule =>
|
|
354
|
+
(value) => {
|
|
355
|
+
if (!allowed.includes(value as T)) {
|
|
356
|
+
return msg ?? `Must be one of: ${allowed.join(', ')}`
|
|
357
|
+
}
|
|
358
|
+
return true
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Check if value is NOT in a list of disallowed values */
|
|
362
|
+
export const notOneOf = <T>(disallowed: T[], msg?: string): ValidationRule =>
|
|
363
|
+
(value) => {
|
|
364
|
+
if (disallowed.includes(value as T)) {
|
|
365
|
+
return msg ?? `Cannot be: ${disallowed.join(', ')}`
|
|
366
|
+
}
|
|
367
|
+
return true
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
// ============================================================================
|
|
372
|
+
// Debounce utility
|
|
373
|
+
// ============================================================================
|
|
374
|
+
|
|
375
|
+
function debounce<T extends (...args: unknown[]) => unknown>(
|
|
376
|
+
fn: T,
|
|
377
|
+
delay: number
|
|
378
|
+
): (...args: Parameters<T>) => void {
|
|
379
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
380
|
+
return (...args: Parameters<T>) => {
|
|
381
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
382
|
+
timeoutId = setTimeout(() => fn(...args), delay)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ============================================================================
|
|
387
|
+
// Storage utilities
|
|
388
|
+
// ============================================================================
|
|
389
|
+
|
|
390
|
+
function getStorage(type: 'localStorage' | 'sessionStorage'): Storage | null {
|
|
391
|
+
try {
|
|
392
|
+
return type === 'localStorage' ? localStorage : sessionStorage
|
|
393
|
+
} catch {
|
|
394
|
+
return null
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function loadFromStorage<T>(key: string, storage: Storage | null): T | null {
|
|
399
|
+
if (!storage) return null
|
|
400
|
+
try {
|
|
401
|
+
const data = storage.getItem(key)
|
|
402
|
+
return data ? JSON.parse(data) : null
|
|
403
|
+
} catch {
|
|
404
|
+
return null
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function saveToStorage(key: string, data: unknown, storage: Storage | null): void {
|
|
409
|
+
if (!storage) return
|
|
410
|
+
try {
|
|
411
|
+
storage.setItem(key, JSON.stringify(data))
|
|
412
|
+
} catch {
|
|
413
|
+
// Storage full or unavailable
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function removeFromStorage(key: string, storage: Storage | null): void {
|
|
418
|
+
if (!storage) return
|
|
419
|
+
try {
|
|
420
|
+
storage.removeItem(key)
|
|
421
|
+
} catch {
|
|
422
|
+
// Storage unavailable
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ============================================================================
|
|
427
|
+
// Zod validation helper
|
|
428
|
+
// ============================================================================
|
|
429
|
+
|
|
430
|
+
function validateWithZod<TValues>(
|
|
431
|
+
schema: ZodLikeSchema<TValues>,
|
|
432
|
+
values: Record<string, unknown>,
|
|
433
|
+
fieldName?: string
|
|
434
|
+
): Record<string, string> {
|
|
435
|
+
const result = schema.safeParse(values)
|
|
436
|
+
const errors: Record<string, string> = {}
|
|
437
|
+
|
|
438
|
+
if (!result.success) {
|
|
439
|
+
for (const err of result.error.errors) {
|
|
440
|
+
const path = err.path[0]?.toString() ?? ''
|
|
441
|
+
if (!fieldName || path === fieldName) {
|
|
442
|
+
if (!errors[path]) {
|
|
443
|
+
errors[path] = err.message
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return errors
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ============================================================================
|
|
453
|
+
// Main Composable: useForm
|
|
454
|
+
// ============================================================================
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Comprehensive form management composable with validation, submission handling,
|
|
458
|
+
* and reactive state management.
|
|
459
|
+
*
|
|
460
|
+
* @example
|
|
461
|
+
* const form = useForm({
|
|
462
|
+
* initialValues: { email: '', password: '' },
|
|
463
|
+
* fields: {
|
|
464
|
+
* email: { rules: [required(), email()] },
|
|
465
|
+
* password: { rules: [required(), minLength(8)] }
|
|
466
|
+
* }
|
|
467
|
+
* })
|
|
468
|
+
*
|
|
469
|
+
* // With Zod schema
|
|
470
|
+
* const form = useForm({
|
|
471
|
+
* schema: z.object({
|
|
472
|
+
* email: z.string().email(),
|
|
473
|
+
* password: z.string().min(8)
|
|
474
|
+
* })
|
|
475
|
+
* })
|
|
476
|
+
*
|
|
477
|
+
* const onSubmit = form.handleSubmit(async (values) => {
|
|
478
|
+
* await api.login(values)
|
|
479
|
+
* })
|
|
480
|
+
*/
|
|
481
|
+
export function useForm<TValues extends Record<string, unknown> = Record<string, unknown>>(
|
|
482
|
+
options: UseFormOptions<TValues>
|
|
483
|
+
): FormState<TValues> {
|
|
484
|
+
const {
|
|
485
|
+
initialValues = {},
|
|
486
|
+
fields: fieldsConfig = {},
|
|
487
|
+
schema,
|
|
488
|
+
debounceDelay = 300,
|
|
489
|
+
mode = 'onChange',
|
|
490
|
+
persist
|
|
491
|
+
} = options
|
|
492
|
+
|
|
493
|
+
// Internal field storage with proper typing
|
|
494
|
+
const fields: Record<string, FieldState<unknown>> = {}
|
|
495
|
+
const initialValuesStore: Record<string, unknown> = { ...initialValues }
|
|
496
|
+
const fieldDependencies: Record<string, string[]> = {}
|
|
497
|
+
|
|
498
|
+
// Storage for persistence
|
|
499
|
+
const storage = persist ? getStorage(persist.storage ?? 'localStorage') : null
|
|
500
|
+
const persistDebounce = persist?.debounce ?? 500
|
|
501
|
+
|
|
502
|
+
// Form-level state
|
|
503
|
+
const isSubmitting = ref(false)
|
|
504
|
+
const submitError = ref<string | null>(null)
|
|
505
|
+
const submitCount = ref(0)
|
|
506
|
+
const isSubmitSuccessful = ref(false)
|
|
507
|
+
|
|
508
|
+
// Get all values as object
|
|
509
|
+
const getValues = (): TValues => {
|
|
510
|
+
const values: Record<string, unknown> = {}
|
|
511
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
512
|
+
values[name] = field.value.value
|
|
513
|
+
}
|
|
514
|
+
return values as TValues
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Get persistable values (excluding fields with persist: false)
|
|
518
|
+
const getPersistableValues = (): Record<string, unknown> => {
|
|
519
|
+
const values: Record<string, unknown> = {}
|
|
520
|
+
const configMap = fieldsConfig as Record<string, FieldConfig | undefined>
|
|
521
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
522
|
+
const config = configMap[name]
|
|
523
|
+
if (config?.persist !== false) {
|
|
524
|
+
values[name] = field.value.value
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return values
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Debounced save to storage
|
|
531
|
+
const debouncedSave = persist
|
|
532
|
+
? debounce(() => {
|
|
533
|
+
saveToStorage(persist.key, getPersistableValues(), storage)
|
|
534
|
+
}, persistDebounce)
|
|
535
|
+
: () => {}
|
|
536
|
+
|
|
537
|
+
// Load persisted values
|
|
538
|
+
const loadPersistedValues = (): Record<string, unknown> | null => {
|
|
539
|
+
if (!persist) return null
|
|
540
|
+
return loadFromStorage<Record<string, unknown>>(persist.key, storage)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Get field names from schema or fieldsConfig
|
|
544
|
+
const getFieldNames = (): string[] => {
|
|
545
|
+
if (schema?.shape) {
|
|
546
|
+
return Object.keys(schema.shape)
|
|
547
|
+
}
|
|
548
|
+
// Combine keys from fieldsConfig and initialValues
|
|
549
|
+
const keys = new Set([
|
|
550
|
+
...Object.keys(fieldsConfig),
|
|
551
|
+
...Object.keys(initialValues)
|
|
552
|
+
])
|
|
553
|
+
return Array.from(keys)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Initialize each field
|
|
557
|
+
const fieldNames = getFieldNames()
|
|
558
|
+
const persistedValues = loadPersistedValues()
|
|
559
|
+
const configMap = fieldsConfig as Record<string, FieldConfig | undefined>
|
|
560
|
+
|
|
561
|
+
for (const name of fieldNames) {
|
|
562
|
+
const config = configMap[name]
|
|
563
|
+
|
|
564
|
+
// Use persisted value, then initial value, then empty string
|
|
565
|
+
const persistedValue = persistedValues?.[name]
|
|
566
|
+
const fieldInitialValue = persistedValue !== undefined
|
|
567
|
+
? persistedValue
|
|
568
|
+
: (initialValuesStore[name] ?? '')
|
|
569
|
+
|
|
570
|
+
const value = ref<unknown>(fieldInitialValue)
|
|
571
|
+
const error = ref('')
|
|
572
|
+
const touched = ref(false)
|
|
573
|
+
const dirty = ref(false)
|
|
574
|
+
const initialValue = ref<unknown>(initialValuesStore[name] ?? '')
|
|
575
|
+
|
|
576
|
+
const valid = computed(() => !error.value)
|
|
577
|
+
|
|
578
|
+
// Store dependencies for this field
|
|
579
|
+
if (config?.deps) {
|
|
580
|
+
fieldDependencies[name] = config.deps
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const validate = async (): Promise<boolean> => {
|
|
584
|
+
const formData = getValues()
|
|
585
|
+
|
|
586
|
+
// Validate with Zod schema if provided
|
|
587
|
+
if (schema) {
|
|
588
|
+
const zodErrors = validateWithZod(schema, formData as Record<string, unknown>, name)
|
|
589
|
+
if (zodErrors[name]) {
|
|
590
|
+
error.value = zodErrors[name]
|
|
591
|
+
return false
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Validate with custom rules
|
|
596
|
+
const rules = config?.rules ?? []
|
|
597
|
+
for (const rule of rules) {
|
|
598
|
+
const result = await rule(value.value, formData as Record<string, unknown>)
|
|
599
|
+
if (result !== true) {
|
|
600
|
+
error.value = result
|
|
601
|
+
return false
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
error.value = ''
|
|
606
|
+
return true
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Create debounced validate for async operations
|
|
610
|
+
const fieldDebounce = config?.debounce ?? debounceDelay
|
|
611
|
+
const debouncedValidate = debounce(validate, fieldDebounce)
|
|
612
|
+
|
|
613
|
+
const reset = () => {
|
|
614
|
+
value.value = initialValue.value
|
|
615
|
+
error.value = ''
|
|
616
|
+
touched.value = false
|
|
617
|
+
dirty.value = false
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Determine validation trigger based on config and mode
|
|
621
|
+
const validateOn = config?.validateOn ?? (mode === 'onChange' ? 'input' : mode === 'onBlur' ? 'blur' : 'submit')
|
|
622
|
+
|
|
623
|
+
// Watch for changes to trigger validation
|
|
624
|
+
watch(value, async (newVal, oldVal) => {
|
|
625
|
+
if (newVal !== oldVal) {
|
|
626
|
+
dirty.value = newVal !== initialValue.value
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Save to storage if persistence is enabled
|
|
630
|
+
if (persist && config?.persist !== false) {
|
|
631
|
+
debouncedSave()
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Only validate if touched or dirty
|
|
635
|
+
if (touched.value || dirty.value) {
|
|
636
|
+
if (validateOn === 'input') {
|
|
637
|
+
// Check if any rule is async
|
|
638
|
+
const hasAsyncRules = (config?.rules ?? []).some(
|
|
639
|
+
rule => rule.constructor.name === 'AsyncFunction'
|
|
640
|
+
)
|
|
641
|
+
if (hasAsyncRules) {
|
|
642
|
+
debouncedValidate()
|
|
643
|
+
} else {
|
|
644
|
+
await validate()
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Trigger revalidation of dependent fields
|
|
650
|
+
for (const [depFieldName, deps] of Object.entries(fieldDependencies)) {
|
|
651
|
+
if (deps.includes(name) && fields[depFieldName]) {
|
|
652
|
+
const depField = fields[depFieldName]
|
|
653
|
+
if (depField.touched.value || depField.dirty.value) {
|
|
654
|
+
depField.validate()
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
fields[name] = {
|
|
661
|
+
value,
|
|
662
|
+
error,
|
|
663
|
+
touched,
|
|
664
|
+
dirty,
|
|
665
|
+
valid,
|
|
666
|
+
validate,
|
|
667
|
+
reset
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Computed: all errors
|
|
672
|
+
const errors = computed(() => {
|
|
673
|
+
const errs: Record<string, string> = {}
|
|
674
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
675
|
+
errs[name] = field.error.value
|
|
676
|
+
}
|
|
677
|
+
return errs
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
// Computed: form is valid
|
|
681
|
+
const valid = computed(() => {
|
|
682
|
+
return Object.values(fields).every(field => field.valid.value)
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
// Computed: form is dirty (compared to initial values)
|
|
686
|
+
const dirty = computed(() => {
|
|
687
|
+
return Object.entries(fields).some(([name, field]) => {
|
|
688
|
+
const initial = initialValuesStore[name] ?? ''
|
|
689
|
+
return field.value.value !== initial
|
|
690
|
+
})
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
// Computed: form is touched
|
|
694
|
+
const touched = computed(() => {
|
|
695
|
+
return Object.values(fields).some(field => field.touched.value)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
// Validate all fields
|
|
699
|
+
const validate = async (): Promise<boolean> => {
|
|
700
|
+
const results = await Promise.all(
|
|
701
|
+
Object.values(fields).map(field => field.validate())
|
|
702
|
+
)
|
|
703
|
+
return results.every(Boolean)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Reset all fields (optionally with new values)
|
|
707
|
+
const reset = (values?: Partial<TValues>) => {
|
|
708
|
+
if (values) {
|
|
709
|
+
// Update initial values and reset to them
|
|
710
|
+
Object.assign(initialValuesStore, values)
|
|
711
|
+
for (const [name, val] of Object.entries(values)) {
|
|
712
|
+
if (fields[name]) {
|
|
713
|
+
(fields[name].value as Ref<unknown>).value = val
|
|
714
|
+
fields[name].error.value = ''
|
|
715
|
+
fields[name].touched.value = false
|
|
716
|
+
fields[name].dirty.value = false
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
} else {
|
|
720
|
+
Object.values(fields).forEach(field => field.reset())
|
|
721
|
+
}
|
|
722
|
+
submitError.value = null
|
|
723
|
+
isSubmitSuccessful.value = false
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Reset a single field
|
|
727
|
+
const resetField = (name: keyof TValues) => {
|
|
728
|
+
if (fields[name as string]) {
|
|
729
|
+
fields[name as string].reset()
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Set a single field value
|
|
734
|
+
const setValue = <K extends keyof TValues>(name: K, value: TValues[K]) => {
|
|
735
|
+
if (fields[name as string]) {
|
|
736
|
+
(fields[name as string].value as Ref<unknown>).value = value
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Set multiple field values
|
|
741
|
+
const setValues = (values: Partial<TValues>) => {
|
|
742
|
+
for (const [name, val] of Object.entries(values)) {
|
|
743
|
+
if (fields[name]) {
|
|
744
|
+
(fields[name].value as Ref<unknown>).value = val
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Get a single field value
|
|
750
|
+
const getFieldValue = <K extends keyof TValues>(name: K): TValues[K] => {
|
|
751
|
+
return fields[name as string]?.value.value as TValues[K]
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Set a field error manually
|
|
755
|
+
const setError = (name: keyof TValues, errorMsg: string) => {
|
|
756
|
+
if (fields[name as string]) {
|
|
757
|
+
fields[name as string].error.value = errorMsg
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Clear a field error
|
|
762
|
+
const clearError = (name: keyof TValues) => {
|
|
763
|
+
if (fields[name as string]) {
|
|
764
|
+
fields[name as string].error.value = ''
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Clear all errors
|
|
769
|
+
const clearErrors = () => {
|
|
770
|
+
Object.values(fields).forEach(field => {
|
|
771
|
+
field.error.value = ''
|
|
772
|
+
})
|
|
773
|
+
submitError.value = null
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Set field touched state
|
|
777
|
+
const setFieldTouched = (name: keyof TValues, touchedState = true) => {
|
|
778
|
+
if (fields[name as string]) {
|
|
779
|
+
fields[name as string].touched.value = touchedState
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Set field dirty state
|
|
784
|
+
const setFieldDirty = (name: keyof TValues, dirtyState = true) => {
|
|
785
|
+
if (fields[name as string]) {
|
|
786
|
+
fields[name as string].dirty.value = dirtyState
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Clear persisted data
|
|
791
|
+
const clearPersisted = () => {
|
|
792
|
+
if (persist) {
|
|
793
|
+
removeFromStorage(persist.key, storage)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Handle form submission
|
|
798
|
+
const handleSubmit = <TResult = void>(
|
|
799
|
+
onSubmit: (values: TValues) => Promise<TResult> | TResult,
|
|
800
|
+
onError?: (error: unknown) => void
|
|
801
|
+
): (() => Promise<TResult | undefined>) => {
|
|
802
|
+
return async () => {
|
|
803
|
+
submitCount.value++
|
|
804
|
+
isSubmitting.value = true
|
|
805
|
+
submitError.value = null
|
|
806
|
+
isSubmitSuccessful.value = false
|
|
807
|
+
|
|
808
|
+
try {
|
|
809
|
+
// Validate all fields first
|
|
810
|
+
const isValid = await validate()
|
|
811
|
+
if (!isValid) {
|
|
812
|
+
isSubmitting.value = false
|
|
813
|
+
return undefined
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Execute submit handler
|
|
817
|
+
const values = getValues()
|
|
818
|
+
const result = await onSubmit(values)
|
|
819
|
+
isSubmitSuccessful.value = true
|
|
820
|
+
return result
|
|
821
|
+
} catch (err) {
|
|
822
|
+
submitError.value = err instanceof Error ? err.message : String(err)
|
|
823
|
+
if (onError) {
|
|
824
|
+
onError(err)
|
|
825
|
+
}
|
|
826
|
+
return undefined
|
|
827
|
+
} finally {
|
|
828
|
+
isSubmitting.value = false
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Watch entire form for changes
|
|
834
|
+
const watchForm = (callback: (values: TValues) => void): WatchStopHandle => {
|
|
835
|
+
const fieldRefs = Object.values(fields).map(f => f.value)
|
|
836
|
+
return watch(
|
|
837
|
+
fieldRefs,
|
|
838
|
+
() => callback(getValues()),
|
|
839
|
+
{ deep: true }
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Watch a specific field
|
|
844
|
+
const watchField = <K extends keyof TValues>(
|
|
845
|
+
name: K,
|
|
846
|
+
callback: (value: TValues[K], oldValue: TValues[K]) => void
|
|
847
|
+
): WatchStopHandle => {
|
|
848
|
+
const field = fields[name as string]
|
|
849
|
+
if (!field) {
|
|
850
|
+
console.warn(`[useForm] Field "${String(name)}" not found`)
|
|
851
|
+
return () => {}
|
|
852
|
+
}
|
|
853
|
+
return watch(
|
|
854
|
+
field.value,
|
|
855
|
+
(newVal, oldVal) => callback(newVal as TValues[K], oldVal as TValues[K])
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Mount hook for persistence initialization
|
|
860
|
+
if (persist) {
|
|
861
|
+
onMounted(() => {
|
|
862
|
+
// Values are already loaded in field initialization
|
|
863
|
+
// This hook can be used for any additional setup
|
|
864
|
+
})
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return {
|
|
868
|
+
fields: fields as TypedFields<TValues>,
|
|
869
|
+
errors,
|
|
870
|
+
valid,
|
|
871
|
+
dirty,
|
|
872
|
+
touched,
|
|
873
|
+
validate,
|
|
874
|
+
reset,
|
|
875
|
+
getValues,
|
|
876
|
+
// Submission state
|
|
877
|
+
isSubmitting,
|
|
878
|
+
submitError,
|
|
879
|
+
submitCount,
|
|
880
|
+
isSubmitSuccessful,
|
|
881
|
+
// Value management
|
|
882
|
+
setValue,
|
|
883
|
+
setValues,
|
|
884
|
+
getFieldValue,
|
|
885
|
+
// Error management
|
|
886
|
+
setError,
|
|
887
|
+
clearError,
|
|
888
|
+
clearErrors,
|
|
889
|
+
// Field state management
|
|
890
|
+
resetField,
|
|
891
|
+
setFieldTouched,
|
|
892
|
+
setFieldDirty,
|
|
893
|
+
// Submission
|
|
894
|
+
handleSubmit,
|
|
895
|
+
// Watchers
|
|
896
|
+
watchForm,
|
|
897
|
+
watchField,
|
|
898
|
+
// Persistence
|
|
899
|
+
clearPersisted
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ============================================================================
|
|
904
|
+
// Simple helper for quick field binding
|
|
905
|
+
// ============================================================================
|
|
906
|
+
|
|
907
|
+
export interface SimpleFieldBinding {
|
|
908
|
+
modelValue: unknown
|
|
909
|
+
error: string
|
|
910
|
+
'onUpdate:modelValue': (value: unknown) => void
|
|
911
|
+
onBlur: () => void
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Create bindings for a field to use with v-bind on SInput */
|
|
915
|
+
export function createFieldBindings(field: FieldState): SimpleFieldBinding {
|
|
916
|
+
return {
|
|
917
|
+
modelValue: field.value.value,
|
|
918
|
+
error: field.error.value,
|
|
919
|
+
'onUpdate:modelValue': (value: unknown) => {
|
|
920
|
+
field.value.value = value
|
|
921
|
+
},
|
|
922
|
+
onBlur: () => {
|
|
923
|
+
field.touched.value = true
|
|
924
|
+
field.validate()
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Helper to get field bindings for use with FormField scoped slots
|
|
931
|
+
* Returns reactive bindings that automatically sync with form validation
|
|
932
|
+
*/
|
|
933
|
+
export function useFormFieldBindings(field: FieldState | null) {
|
|
934
|
+
if (!field) {
|
|
935
|
+
return {
|
|
936
|
+
modelValue: computed(() => undefined),
|
|
937
|
+
error: computed(() => ''),
|
|
938
|
+
'onUpdate:modelValue': () => {},
|
|
939
|
+
onBlur: () => {},
|
|
940
|
+
onInput: () => {}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return {
|
|
945
|
+
modelValue: field.value,
|
|
946
|
+
error: field.error,
|
|
947
|
+
'onUpdate:modelValue': (value: unknown) => {
|
|
948
|
+
field.value.value = value
|
|
949
|
+
},
|
|
950
|
+
onBlur: () => {
|
|
951
|
+
field.touched.value = true
|
|
952
|
+
field.validate()
|
|
953
|
+
},
|
|
954
|
+
onInput: () => {
|
|
955
|
+
// Validation happens automatically via watchers
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export default useForm
|