@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,1290 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineOptions({ inheritAttrs: false })
|
|
3
|
+
|
|
4
|
+
import { ref, computed, watch, provide, onMounted, onBeforeUnmount, nextTick, type CSSProperties } from 'vue'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
|
|
7
|
+
export interface SelectOption {
|
|
8
|
+
value: any
|
|
9
|
+
label?: string
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
icon?: string
|
|
12
|
+
image?: string
|
|
13
|
+
description?: string
|
|
14
|
+
color?: string
|
|
15
|
+
group?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Props {
|
|
19
|
+
modelValue?: any | any[]
|
|
20
|
+
options?: SelectOption[]
|
|
21
|
+
multiple?: boolean
|
|
22
|
+
searchable?: boolean
|
|
23
|
+
clearable?: boolean
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
loading?: boolean
|
|
26
|
+
placeholder?: string
|
|
27
|
+
size?: 'small' | 'medium' | 'large'
|
|
28
|
+
color?: string
|
|
29
|
+
variant?: 'outlined' | 'filled' | 'underlined'
|
|
30
|
+
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full'
|
|
31
|
+
maxHeight?: string
|
|
32
|
+
closeOnSelect?: boolean
|
|
33
|
+
tagLimit?: number
|
|
34
|
+
noOptionsText?: string
|
|
35
|
+
noResultsText?: string
|
|
36
|
+
label?: string
|
|
37
|
+
error?: string
|
|
38
|
+
hint?: string
|
|
39
|
+
required?: boolean
|
|
40
|
+
teleport?: boolean | string
|
|
41
|
+
placement?: 'bottom' | 'top' | 'auto'
|
|
42
|
+
// New props
|
|
43
|
+
arrowIcon?: string
|
|
44
|
+
menuWidth?: string | number
|
|
45
|
+
menuAlign?: 'start' | 'end' | 'center'
|
|
46
|
+
creatable?: boolean
|
|
47
|
+
createText?: string
|
|
48
|
+
labelPlacement?: 'top' | 'top-left' | 'top-center' | 'top-right' | 'bottom' | 'bottom-left' | 'bottom-center' | 'bottom-right' | 'left' | 'left-top' | 'left-center' | 'left-bottom' | 'right' | 'right-top' | 'right-center' | 'right-bottom'
|
|
49
|
+
// Vuesax-inspired props
|
|
50
|
+
labelPlaceholder?: string // Float label that animates from inside to top
|
|
51
|
+
filter?: boolean // Inline filter - type directly in trigger
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
55
|
+
modelValue: null,
|
|
56
|
+
options: () => [],
|
|
57
|
+
multiple: false,
|
|
58
|
+
searchable: false,
|
|
59
|
+
clearable: false,
|
|
60
|
+
disabled: false,
|
|
61
|
+
loading: false,
|
|
62
|
+
placeholder: 'Select...',
|
|
63
|
+
size: 'medium',
|
|
64
|
+
color: undefined,
|
|
65
|
+
variant: 'outlined',
|
|
66
|
+
rounded: 'md',
|
|
67
|
+
maxHeight: '280px',
|
|
68
|
+
closeOnSelect: undefined,
|
|
69
|
+
tagLimit: 3,
|
|
70
|
+
noOptionsText: 'No options available',
|
|
71
|
+
noResultsText: 'No results found',
|
|
72
|
+
label: undefined,
|
|
73
|
+
error: undefined,
|
|
74
|
+
hint: undefined,
|
|
75
|
+
required: false,
|
|
76
|
+
teleport: true,
|
|
77
|
+
placement: 'auto',
|
|
78
|
+
// New props defaults
|
|
79
|
+
arrowIcon: 'chevron-down',
|
|
80
|
+
menuWidth: undefined,
|
|
81
|
+
menuAlign: 'start',
|
|
82
|
+
creatable: false,
|
|
83
|
+
createText: 'Create "{query}"',
|
|
84
|
+
labelPlacement: 'top',
|
|
85
|
+
// Vuesax-inspired props defaults
|
|
86
|
+
labelPlaceholder: undefined,
|
|
87
|
+
filter: false
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const emit = defineEmits<{
|
|
91
|
+
'update:modelValue': [value: any | any[]]
|
|
92
|
+
'change': [value: any | any[], event?: Event]
|
|
93
|
+
'open': []
|
|
94
|
+
'close': []
|
|
95
|
+
'search': [query: string]
|
|
96
|
+
'focus': [event: FocusEvent]
|
|
97
|
+
'blur': [event: FocusEvent]
|
|
98
|
+
'create': [value: string]
|
|
99
|
+
}>()
|
|
100
|
+
|
|
101
|
+
// Refs
|
|
102
|
+
const triggerRef = ref<HTMLElement | null>(null)
|
|
103
|
+
const dropdownRef = ref<HTMLElement | null>(null)
|
|
104
|
+
const searchInputRef = ref<HTMLInputElement | null>(null)
|
|
105
|
+
const filterInputRef = ref<HTMLInputElement | null>(null) // For inline filter
|
|
106
|
+
const isOpen = ref(false)
|
|
107
|
+
const isFocused = ref(false)
|
|
108
|
+
const searchQuery = ref('')
|
|
109
|
+
const filterQuery = ref('') // For inline filter mode
|
|
110
|
+
const isFilterActive = ref(false) // Track if user is typing in filter mode
|
|
111
|
+
const highlightedIndex = ref(-1)
|
|
112
|
+
const registeredOptions = ref<SelectOption[]>([])
|
|
113
|
+
const dropdownPosition = ref<{ top?: number; bottom?: number; left: number; width: number; placement: 'top' | 'bottom' }>({ top: 0, left: 0, width: 0, placement: 'bottom' })
|
|
114
|
+
|
|
115
|
+
// Computed
|
|
116
|
+
const shouldCloseOnSelect = computed(() => {
|
|
117
|
+
if (props.closeOnSelect !== undefined) return props.closeOnSelect
|
|
118
|
+
return !props.multiple
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const selectedValues = computed(() => {
|
|
122
|
+
if (props.modelValue === null || props.modelValue === undefined) return []
|
|
123
|
+
return props.multiple ? (Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue]) : [props.modelValue]
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const selectedOptions = computed(() => {
|
|
127
|
+
return selectedValues.value.map(val => {
|
|
128
|
+
const fromOptions = props.options.find(o => o.value === val)
|
|
129
|
+
if (fromOptions) return fromOptions
|
|
130
|
+
const fromRegistered = registeredOptions.value.find(o => o.value === val)
|
|
131
|
+
if (fromRegistered) return fromRegistered
|
|
132
|
+
return { value: val, label: String(val) }
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const displayValue = computed(() => {
|
|
137
|
+
if (selectedOptions.value.length === 0) return ''
|
|
138
|
+
if (props.multiple) {
|
|
139
|
+
return selectedOptions.value.map(o => o.label ?? o.value).join(', ')
|
|
140
|
+
}
|
|
141
|
+
return selectedOptions.value[0]?.label ?? selectedOptions.value[0]?.value ?? ''
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Active query - uses filterQuery for inline filter mode, searchQuery for searchable mode
|
|
145
|
+
const activeQuery = computed(() => {
|
|
146
|
+
if (props.filter) return filterQuery.value
|
|
147
|
+
return searchQuery.value
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const filteredOptions = computed(() => {
|
|
151
|
+
if (!activeQuery.value) return props.options
|
|
152
|
+
const query = activeQuery.value.toLowerCase()
|
|
153
|
+
return props.options.filter(option => {
|
|
154
|
+
const label = (option.label ?? String(option.value)).toLowerCase()
|
|
155
|
+
return label.includes(query)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Float label should show above when focused/open or has value
|
|
160
|
+
const showFloatLabel = computed(() => {
|
|
161
|
+
return props.labelPlaceholder && (isFocused.value || isOpen.value || hasValue.value || filterQuery.value)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Group options by their group property
|
|
165
|
+
const groupedOptions = computed(() => {
|
|
166
|
+
const groups: Map<string | undefined, SelectOption[]> = new Map()
|
|
167
|
+
|
|
168
|
+
for (const option of filteredOptions.value) {
|
|
169
|
+
const group = option.group
|
|
170
|
+
if (!groups.has(group)) {
|
|
171
|
+
groups.set(group, [])
|
|
172
|
+
}
|
|
173
|
+
groups.get(group)!.push(option)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return groups
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const hasGroups = computed(() => {
|
|
180
|
+
return filteredOptions.value.some(o => o.group)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Show create option when creatable is enabled and query doesn't match any option
|
|
184
|
+
const showCreateOption = computed(() => {
|
|
185
|
+
if (!props.creatable || !activeQuery.value.trim()) return false
|
|
186
|
+
const query = activeQuery.value.toLowerCase().trim()
|
|
187
|
+
return !props.options.some(o =>
|
|
188
|
+
(o.label ?? String(o.value)).toLowerCase() === query
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const createOptionLabel = computed(() => {
|
|
193
|
+
return props.createText.replace('{query}', activeQuery.value.trim())
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const hasValue = computed(() => {
|
|
197
|
+
if (props.multiple) {
|
|
198
|
+
return Array.isArray(props.modelValue) && props.modelValue.length > 0
|
|
199
|
+
}
|
|
200
|
+
return props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== ''
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const visibleTags = computed(() => {
|
|
204
|
+
if (!props.multiple) return []
|
|
205
|
+
return selectedOptions.value.slice(0, props.tagLimit)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const hiddenTagCount = computed(() => {
|
|
209
|
+
if (!props.multiple) return 0
|
|
210
|
+
return Math.max(0, selectedOptions.value.length - props.tagLimit)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Methods
|
|
214
|
+
const isSelected = (value: any) => selectedValues.value.includes(value)
|
|
215
|
+
|
|
216
|
+
const registerOption = (option: { value: any; label: string; disabled: boolean }) => {
|
|
217
|
+
const existingIndex = registeredOptions.value.findIndex(o => o.value === option.value)
|
|
218
|
+
if (existingIndex >= 0) {
|
|
219
|
+
return existingIndex
|
|
220
|
+
}
|
|
221
|
+
registeredOptions.value.push(option)
|
|
222
|
+
return registeredOptions.value.length - 1
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const selectOption = (value: any) => {
|
|
226
|
+
if (props.disabled || props.loading) return
|
|
227
|
+
|
|
228
|
+
let newValue: any
|
|
229
|
+
|
|
230
|
+
if (props.multiple) {
|
|
231
|
+
const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
|
232
|
+
const index = arr.indexOf(value)
|
|
233
|
+
if (index >= 0) {
|
|
234
|
+
arr.splice(index, 1)
|
|
235
|
+
} else {
|
|
236
|
+
arr.push(value)
|
|
237
|
+
}
|
|
238
|
+
newValue = arr
|
|
239
|
+
} else {
|
|
240
|
+
newValue = value
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
emit('update:modelValue', newValue)
|
|
244
|
+
emit('change', newValue)
|
|
245
|
+
|
|
246
|
+
// Clear filter state after selection
|
|
247
|
+
filterQuery.value = ''
|
|
248
|
+
isFilterActive.value = false
|
|
249
|
+
|
|
250
|
+
if (shouldCloseOnSelect.value) {
|
|
251
|
+
close()
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const createOption = () => {
|
|
256
|
+
if (!props.creatable || !activeQuery.value.trim()) return
|
|
257
|
+
const newValue = activeQuery.value.trim()
|
|
258
|
+
emit('create', newValue)
|
|
259
|
+
selectOption(newValue)
|
|
260
|
+
searchQuery.value = ''
|
|
261
|
+
filterQuery.value = ''
|
|
262
|
+
isFilterActive.value = false
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const removeTag = (value: any, event?: Event) => {
|
|
266
|
+
event?.stopPropagation()
|
|
267
|
+
if (props.disabled || props.loading) return
|
|
268
|
+
|
|
269
|
+
if (props.multiple && Array.isArray(props.modelValue)) {
|
|
270
|
+
const newValue = props.modelValue.filter(v => v !== value)
|
|
271
|
+
emit('update:modelValue', newValue)
|
|
272
|
+
emit('change', newValue)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const clear = (event?: Event) => {
|
|
277
|
+
event?.stopPropagation()
|
|
278
|
+
if (props.disabled || props.loading) return
|
|
279
|
+
|
|
280
|
+
const newValue = props.multiple ? [] : null
|
|
281
|
+
emit('update:modelValue', newValue)
|
|
282
|
+
emit('change', newValue)
|
|
283
|
+
searchQuery.value = ''
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const updateDropdownPosition = () => {
|
|
287
|
+
if (!triggerRef.value) return
|
|
288
|
+
|
|
289
|
+
const rect = triggerRef.value.getBoundingClientRect()
|
|
290
|
+
const viewportHeight = window.innerHeight
|
|
291
|
+
const spaceBelow = viewportHeight - rect.bottom
|
|
292
|
+
const spaceAbove = rect.top
|
|
293
|
+
const dropdownHeight = parseInt(props.maxHeight) || 280
|
|
294
|
+
|
|
295
|
+
let placement: 'top' | 'bottom' = 'bottom'
|
|
296
|
+
|
|
297
|
+
if (props.placement === 'auto') {
|
|
298
|
+
placement = spaceBelow < dropdownHeight && spaceAbove > spaceBelow ? 'top' : 'bottom'
|
|
299
|
+
} else {
|
|
300
|
+
placement = props.placement === 'top' ? 'top' : 'bottom'
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
dropdownPosition.value = {
|
|
304
|
+
top: placement === 'bottom' ? rect.bottom + 2 : undefined,
|
|
305
|
+
bottom: placement === 'top' ? window.innerHeight - rect.top + 2 : undefined,
|
|
306
|
+
left: rect.left,
|
|
307
|
+
width: rect.width,
|
|
308
|
+
placement
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const open = () => {
|
|
313
|
+
if (props.disabled || props.loading || isOpen.value) return
|
|
314
|
+
|
|
315
|
+
isOpen.value = true
|
|
316
|
+
highlightedIndex.value = -1
|
|
317
|
+
updateDropdownPosition()
|
|
318
|
+
emit('open')
|
|
319
|
+
|
|
320
|
+
nextTick(() => {
|
|
321
|
+
// Focus appropriate input based on mode
|
|
322
|
+
if (props.filter && filterInputRef.value) {
|
|
323
|
+
filterInputRef.value.focus()
|
|
324
|
+
isFilterActive.value = true
|
|
325
|
+
} else if (props.searchable && searchInputRef.value) {
|
|
326
|
+
searchInputRef.value.focus()
|
|
327
|
+
}
|
|
328
|
+
// Find first selected option to highlight
|
|
329
|
+
if (selectedValues.value.length > 0) {
|
|
330
|
+
const allOptions = props.options.length > 0 ? props.options : registeredOptions.value
|
|
331
|
+
const selectedIndex = allOptions.findIndex(o => o.value === selectedValues.value[0])
|
|
332
|
+
if (selectedIndex >= 0) {
|
|
333
|
+
highlightedIndex.value = selectedIndex
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const close = () => {
|
|
340
|
+
if (!isOpen.value) return
|
|
341
|
+
|
|
342
|
+
isOpen.value = false
|
|
343
|
+
searchQuery.value = ''
|
|
344
|
+
filterQuery.value = ''
|
|
345
|
+
isFilterActive.value = false
|
|
346
|
+
highlightedIndex.value = -1
|
|
347
|
+
emit('close')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const toggle = () => {
|
|
351
|
+
if (isOpen.value) {
|
|
352
|
+
close()
|
|
353
|
+
} else {
|
|
354
|
+
open()
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const handleFocus = (event: FocusEvent) => {
|
|
359
|
+
isFocused.value = true
|
|
360
|
+
emit('focus', event)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const handleBlur = (event: FocusEvent) => {
|
|
364
|
+
// Check if focus moved to dropdown
|
|
365
|
+
const relatedTarget = event.relatedTarget as HTMLElement
|
|
366
|
+
if (dropdownRef.value?.contains(relatedTarget)) return
|
|
367
|
+
|
|
368
|
+
isFocused.value = false
|
|
369
|
+
emit('blur', event)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
373
|
+
if (props.disabled || props.loading) return
|
|
374
|
+
|
|
375
|
+
const allOptions = filteredOptions.value.length > 0 ? filteredOptions.value : registeredOptions.value
|
|
376
|
+
const enabledOptions = allOptions.filter(o => !o.disabled)
|
|
377
|
+
|
|
378
|
+
switch (event.key) {
|
|
379
|
+
case 'Enter':
|
|
380
|
+
event.preventDefault()
|
|
381
|
+
if (!isOpen.value) {
|
|
382
|
+
open()
|
|
383
|
+
} else if (highlightedIndex.value >= 0 && allOptions[highlightedIndex.value]) {
|
|
384
|
+
selectOption(allOptions[highlightedIndex.value].value)
|
|
385
|
+
} else if (props.creatable && activeQuery.value.trim()) {
|
|
386
|
+
createOption()
|
|
387
|
+
}
|
|
388
|
+
break
|
|
389
|
+
|
|
390
|
+
case ' ':
|
|
391
|
+
// In filter mode, space should type a space, not toggle
|
|
392
|
+
if (props.filter && isFilterActive.value) {
|
|
393
|
+
return // Let the space character be typed
|
|
394
|
+
}
|
|
395
|
+
event.preventDefault()
|
|
396
|
+
if (!isOpen.value) {
|
|
397
|
+
open()
|
|
398
|
+
} else if (highlightedIndex.value >= 0 && allOptions[highlightedIndex.value]) {
|
|
399
|
+
selectOption(allOptions[highlightedIndex.value].value)
|
|
400
|
+
}
|
|
401
|
+
break
|
|
402
|
+
|
|
403
|
+
case 'Escape':
|
|
404
|
+
event.preventDefault()
|
|
405
|
+
close()
|
|
406
|
+
if (props.filter) {
|
|
407
|
+
filterInputRef.value?.blur()
|
|
408
|
+
}
|
|
409
|
+
triggerRef.value?.focus()
|
|
410
|
+
break
|
|
411
|
+
|
|
412
|
+
case 'ArrowDown':
|
|
413
|
+
event.preventDefault()
|
|
414
|
+
if (!isOpen.value) {
|
|
415
|
+
open()
|
|
416
|
+
} else {
|
|
417
|
+
// Find next enabled option
|
|
418
|
+
let nextIndex = highlightedIndex.value + 1
|
|
419
|
+
while (nextIndex < allOptions.length && allOptions[nextIndex]?.disabled) {
|
|
420
|
+
nextIndex++
|
|
421
|
+
}
|
|
422
|
+
if (nextIndex < allOptions.length) {
|
|
423
|
+
highlightedIndex.value = nextIndex
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
break
|
|
427
|
+
|
|
428
|
+
case 'ArrowUp':
|
|
429
|
+
event.preventDefault()
|
|
430
|
+
if (!isOpen.value) {
|
|
431
|
+
open()
|
|
432
|
+
} else {
|
|
433
|
+
// Find previous enabled option
|
|
434
|
+
let prevIndex = highlightedIndex.value - 1
|
|
435
|
+
while (prevIndex >= 0 && allOptions[prevIndex]?.disabled) {
|
|
436
|
+
prevIndex--
|
|
437
|
+
}
|
|
438
|
+
if (prevIndex >= 0) {
|
|
439
|
+
highlightedIndex.value = prevIndex
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
break
|
|
443
|
+
|
|
444
|
+
case 'Home':
|
|
445
|
+
event.preventDefault()
|
|
446
|
+
if (isOpen.value) {
|
|
447
|
+
highlightedIndex.value = enabledOptions.length > 0 ? allOptions.indexOf(enabledOptions[0]) : 0
|
|
448
|
+
}
|
|
449
|
+
break
|
|
450
|
+
|
|
451
|
+
case 'End':
|
|
452
|
+
event.preventDefault()
|
|
453
|
+
if (isOpen.value) {
|
|
454
|
+
highlightedIndex.value = enabledOptions.length > 0
|
|
455
|
+
? allOptions.indexOf(enabledOptions[enabledOptions.length - 1])
|
|
456
|
+
: allOptions.length - 1
|
|
457
|
+
}
|
|
458
|
+
break
|
|
459
|
+
|
|
460
|
+
case 'Tab':
|
|
461
|
+
close()
|
|
462
|
+
break
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const handleSearchInput = (event: Event) => {
|
|
467
|
+
const target = event.target as HTMLInputElement
|
|
468
|
+
searchQuery.value = target.value
|
|
469
|
+
emit('search', target.value)
|
|
470
|
+
highlightedIndex.value = 0
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Handle inline filter input
|
|
474
|
+
const handleFilterInput = (event: Event) => {
|
|
475
|
+
const target = event.target as HTMLInputElement
|
|
476
|
+
filterQuery.value = target.value
|
|
477
|
+
isFilterActive.value = true
|
|
478
|
+
emit('search', target.value)
|
|
479
|
+
highlightedIndex.value = 0
|
|
480
|
+
|
|
481
|
+
// Auto-open dropdown when typing
|
|
482
|
+
if (!isOpen.value && target.value) {
|
|
483
|
+
open()
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Handle filter input focus
|
|
488
|
+
const handleFilterFocus = (event: FocusEvent) => {
|
|
489
|
+
isFocused.value = true
|
|
490
|
+
isFilterActive.value = true
|
|
491
|
+
emit('focus', event)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Handle filter input blur
|
|
495
|
+
const handleFilterBlur = (event: FocusEvent) => {
|
|
496
|
+
const relatedTarget = event.relatedTarget as HTMLElement
|
|
497
|
+
if (dropdownRef.value?.contains(relatedTarget)) return
|
|
498
|
+
if (triggerRef.value?.contains(relatedTarget)) return
|
|
499
|
+
|
|
500
|
+
isFocused.value = false
|
|
501
|
+
isFilterActive.value = false
|
|
502
|
+
|
|
503
|
+
// Reset filter query when blurring without selecting
|
|
504
|
+
if (!isOpen.value) {
|
|
505
|
+
filterQuery.value = ''
|
|
506
|
+
}
|
|
507
|
+
emit('blur', event)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
511
|
+
const target = event.target as HTMLElement
|
|
512
|
+
if (
|
|
513
|
+
!triggerRef.value?.contains(target) &&
|
|
514
|
+
!dropdownRef.value?.contains(target)
|
|
515
|
+
) {
|
|
516
|
+
close()
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Watchers
|
|
521
|
+
watch(isOpen, (val) => {
|
|
522
|
+
if (val) {
|
|
523
|
+
window.addEventListener('scroll', updateDropdownPosition, true)
|
|
524
|
+
window.addEventListener('resize', updateDropdownPosition)
|
|
525
|
+
} else {
|
|
526
|
+
window.removeEventListener('scroll', updateDropdownPosition, true)
|
|
527
|
+
window.removeEventListener('resize', updateDropdownPosition)
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
// Lifecycle
|
|
532
|
+
onMounted(() => {
|
|
533
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
onBeforeUnmount(() => {
|
|
537
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
538
|
+
window.removeEventListener('scroll', updateDropdownPosition, true)
|
|
539
|
+
window.removeEventListener('resize', updateDropdownPosition)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
// Provide context to child options
|
|
543
|
+
provide('s-select-context', {
|
|
544
|
+
modelValue: computed(() => props.modelValue),
|
|
545
|
+
multiple: computed(() => props.multiple),
|
|
546
|
+
highlightedIndex,
|
|
547
|
+
registerOption,
|
|
548
|
+
selectOption,
|
|
549
|
+
isSelected,
|
|
550
|
+
color: computed(() => props.color),
|
|
551
|
+
size: computed(() => props.size)
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
// Size configurations
|
|
555
|
+
const sizeConfig = computed(() => {
|
|
556
|
+
const sizes = {
|
|
557
|
+
small: {
|
|
558
|
+
trigger: 'min-h-8 text-xs',
|
|
559
|
+
padding: 'px-2.5 py-0.5',
|
|
560
|
+
icon: 'text-sm',
|
|
561
|
+
tag: 'text-xs px-1.5 py-0.5',
|
|
562
|
+
label: 'text-xs',
|
|
563
|
+
option: 'py-0.5 px-3 my-0.5 text-xs'
|
|
564
|
+
},
|
|
565
|
+
medium: {
|
|
566
|
+
trigger: 'min-h-10 text-sm',
|
|
567
|
+
padding: 'px-3 py-0.5',
|
|
568
|
+
icon: 'text-base',
|
|
569
|
+
tag: 'text-xs px-2 py-0.5',
|
|
570
|
+
label: 'text-sm',
|
|
571
|
+
option: 'py-1 px-3 my-0.5 text-sm'
|
|
572
|
+
},
|
|
573
|
+
large: {
|
|
574
|
+
trigger: 'min-h-12 text-base',
|
|
575
|
+
padding: 'px-4 py-0.5',
|
|
576
|
+
icon: 'text-lg',
|
|
577
|
+
tag: 'text-sm px-2.5 py-0.5',
|
|
578
|
+
label: 'text-base',
|
|
579
|
+
option: 'py-1.5 px-4 my-0.5 text-base'
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return sizes[props.size]
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
// Rounded classes
|
|
586
|
+
const roundedConfig = computed(() => {
|
|
587
|
+
const radii = {
|
|
588
|
+
none: 'rounded-none',
|
|
589
|
+
sm: 'rounded',
|
|
590
|
+
md: 'rounded-lg',
|
|
591
|
+
lg: 'rounded-xl',
|
|
592
|
+
full: 'rounded-full'
|
|
593
|
+
}
|
|
594
|
+
return radii[props.rounded]
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
// Dropdown rounded classes (max at xl to prevent oval shape)
|
|
598
|
+
const dropdownRoundedConfig = computed(() => {
|
|
599
|
+
const radii = {
|
|
600
|
+
none: 'rounded-none',
|
|
601
|
+
sm: 'rounded',
|
|
602
|
+
md: 'rounded-lg',
|
|
603
|
+
lg: 'rounded-xl',
|
|
604
|
+
full: 'rounded-xl' // Cap at xl for dropdown
|
|
605
|
+
}
|
|
606
|
+
return radii[props.rounded]
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
// Variant styles
|
|
610
|
+
const variantClasses = computed(() => {
|
|
611
|
+
const base = {
|
|
612
|
+
outlined: 'border bg-background border-border hover:border-input',
|
|
613
|
+
filled: 'border-transparent bg-accent',
|
|
614
|
+
underlined: 'border-b border-t-0 border-l-0 border-r-0 rounded-none bg-transparent border-border'
|
|
615
|
+
}
|
|
616
|
+
return base[props.variant]
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
const focusClasses = computed(() => {
|
|
620
|
+
if (!isFocused.value && !isOpen.value) return ''
|
|
621
|
+
if (props.color) return '' // handled by inline style
|
|
622
|
+
return props.variant === 'underlined'
|
|
623
|
+
? 'border-primary'
|
|
624
|
+
: 'ring-2 ring-ring/20 border-primary'
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
// Focus inline style when custom color is set
|
|
628
|
+
const focusStyle = computed<CSSProperties | undefined>(() => {
|
|
629
|
+
if ((!isFocused.value && !isOpen.value) || !props.color) return undefined
|
|
630
|
+
if (props.variant === 'underlined') {
|
|
631
|
+
return { borderColor: props.color }
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
borderColor: props.color,
|
|
635
|
+
boxShadow: `0 0 0 2px color-mix(in srgb, ${props.color} 20%, transparent)`
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
const teleportTarget = computed(() => {
|
|
640
|
+
if (props.teleport === true) return 'body'
|
|
641
|
+
if (typeof props.teleport === 'string') return props.teleport
|
|
642
|
+
return undefined
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// Label placement layout classes
|
|
646
|
+
// Label placement layout classes
|
|
647
|
+
const labelLayoutClasses = computed(() => {
|
|
648
|
+
const [side, align] = props.labelPlacement.split('-') as [string, string | undefined]
|
|
649
|
+
const classes = ['flex', 'w-full'] // Make sure wrapper is full width
|
|
650
|
+
|
|
651
|
+
// Vertical placement (Top / Bottom)
|
|
652
|
+
if (side === 'top' || side === 'bottom') {
|
|
653
|
+
classes.push('gap-1.5') // Gap for vertical
|
|
654
|
+
if (side === 'bottom') classes.push('flex-col-reverse')
|
|
655
|
+
else classes.push('flex-col')
|
|
656
|
+
// Vertical always stretches to keep input full width
|
|
657
|
+
// Text alignment is handled on the label itself
|
|
658
|
+
}
|
|
659
|
+
// Horizontal placement (Left / Right)
|
|
660
|
+
else {
|
|
661
|
+
classes.push('gap-3') // Gap for horizontal
|
|
662
|
+
if (side === 'right') classes.push('flex-row-reverse')
|
|
663
|
+
else classes.push('flex-row') // left
|
|
664
|
+
|
|
665
|
+
// Alignment (Vertical axis for row)
|
|
666
|
+
if (align === 'center') classes.push('items-center')
|
|
667
|
+
else if (align === 'bottom') classes.push('items-end')
|
|
668
|
+
else classes.push('items-start') // default top/start
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return classes.join(' ')
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
const labelClasses = computed(() => {
|
|
675
|
+
const base = 'font-medium text-muted-foreground'
|
|
676
|
+
const [side, align] = props.labelPlacement.split('-')
|
|
677
|
+
|
|
678
|
+
let alignClass = ''
|
|
679
|
+
// Only apply text alignment for vertical layouts
|
|
680
|
+
if (side === 'top' || side === 'bottom') {
|
|
681
|
+
if (align === 'center') alignClass = 'text-center'
|
|
682
|
+
else if (align === 'right') alignClass = 'text-right'
|
|
683
|
+
else alignClass = 'text-left'
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return `${base} ${alignClass} ${sizeConfig.value.label}`
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
// Float label background based on variant
|
|
690
|
+
const floatLabelBackground = computed(() => {
|
|
691
|
+
switch (props.variant) {
|
|
692
|
+
case 'filled':
|
|
693
|
+
return 'var(--s-accent)'
|
|
694
|
+
case 'underlined':
|
|
695
|
+
return 'transparent'
|
|
696
|
+
default: // outlined
|
|
697
|
+
return 'var(--s-background)'
|
|
698
|
+
}
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
// Resolved color for inline styles - falls back to CSS variable
|
|
702
|
+
const resolvedColor = computed(() => props.color ?? 'var(--s-primary)')
|
|
703
|
+
</script>
|
|
704
|
+
|
|
705
|
+
<template>
|
|
706
|
+
<div v-bind="$attrs" :class="cn('s-select relative w-full', labelLayoutClasses, $attrs.class as string)">
|
|
707
|
+
<!-- Static Label (traditional placement) -->
|
|
708
|
+
<label
|
|
709
|
+
v-if="label && !labelPlaceholder"
|
|
710
|
+
class="shrink-0"
|
|
711
|
+
:class="labelClasses"
|
|
712
|
+
>
|
|
713
|
+
{{ label }}
|
|
714
|
+
<span v-if="required" class="text-red-500 ml-0.5">*</span>
|
|
715
|
+
</label>
|
|
716
|
+
|
|
717
|
+
<!-- Select wrapper -->
|
|
718
|
+
<div class="flex-1 min-w-0">
|
|
719
|
+
|
|
720
|
+
<!-- Trigger -->
|
|
721
|
+
<div
|
|
722
|
+
ref="triggerRef"
|
|
723
|
+
role="combobox"
|
|
724
|
+
:aria-expanded="isOpen"
|
|
725
|
+
:aria-haspopup="true"
|
|
726
|
+
:aria-disabled="disabled || loading"
|
|
727
|
+
:tabindex="filter ? -1 : 0"
|
|
728
|
+
class="s-select-trigger relative flex items-center gap-2 cursor-pointer transition-all duration-250 outline-none"
|
|
729
|
+
:class="[
|
|
730
|
+
sizeConfig.trigger,
|
|
731
|
+
sizeConfig.padding,
|
|
732
|
+
roundedConfig,
|
|
733
|
+
variantClasses,
|
|
734
|
+
focusClasses,
|
|
735
|
+
{
|
|
736
|
+
'opacity-50 cursor-not-allowed': disabled,
|
|
737
|
+
'cursor-wait': loading,
|
|
738
|
+
's-select-trigger--float-label': labelPlaceholder,
|
|
739
|
+
's-select-trigger--elevated': (isFocused || isOpen) && !disabled
|
|
740
|
+
}
|
|
741
|
+
]"
|
|
742
|
+
:style="focusStyle"
|
|
743
|
+
@click="!filter && toggle()"
|
|
744
|
+
@keydown="!filter && handleKeydown($event)"
|
|
745
|
+
@focus="!filter && handleFocus($event)"
|
|
746
|
+
@blur="!filter && handleBlur($event)"
|
|
747
|
+
>
|
|
748
|
+
<!-- Float Label (Vuesax-style label-placeholder) -->
|
|
749
|
+
<label
|
|
750
|
+
v-if="labelPlaceholder"
|
|
751
|
+
class="s-float-label absolute left-2.5 transition-all duration-250 ease-out pointer-events-none z-10"
|
|
752
|
+
:class="[
|
|
753
|
+
showFloatLabel
|
|
754
|
+
? 's-float-label--active text-xs px-1.5 opacity-100'
|
|
755
|
+
: 'text-sm translate-y-0 opacity-40',
|
|
756
|
+
{ 'text-muted-foreground': !showFloatLabel }
|
|
757
|
+
]"
|
|
758
|
+
:style="showFloatLabel ? { color: resolvedColor, backgroundColor: floatLabelBackground } : {}"
|
|
759
|
+
>
|
|
760
|
+
{{ labelPlaceholder }}
|
|
761
|
+
<span v-if="required" class="text-red-500 ml-0.5">*</span>
|
|
762
|
+
</label>
|
|
763
|
+
|
|
764
|
+
<!-- Prefix slot -->
|
|
765
|
+
<slot name="prefix" />
|
|
766
|
+
|
|
767
|
+
<!-- Inline Filter Input (Vuesax-style filter) -->
|
|
768
|
+
<template v-if="filter && !multiple">
|
|
769
|
+
<input
|
|
770
|
+
ref="filterInputRef"
|
|
771
|
+
type="text"
|
|
772
|
+
:value="isFilterActive ? filterQuery : (hasValue ? displayValue : '')"
|
|
773
|
+
:placeholder="hasValue ? '' : (labelPlaceholder || placeholder)"
|
|
774
|
+
:disabled="disabled || loading"
|
|
775
|
+
class="s-filter-input flex-1 min-w-0 bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
|
|
776
|
+
:class="{ 'cursor-not-allowed': disabled }"
|
|
777
|
+
@input="handleFilterInput"
|
|
778
|
+
@focus="handleFilterFocus"
|
|
779
|
+
@blur="handleFilterBlur"
|
|
780
|
+
@keydown="handleKeydown"
|
|
781
|
+
@click.stop="!isOpen && open()"
|
|
782
|
+
/>
|
|
783
|
+
</template>
|
|
784
|
+
|
|
785
|
+
<!-- Selected value display (when not in filter mode or in multiple mode) -->
|
|
786
|
+
<div v-else class="flex-1 flex items-center gap-1.5 min-w-0 overflow-hidden">
|
|
787
|
+
<!-- Multiple: Tags -->
|
|
788
|
+
<template v-if="multiple && visibleTags.length > 0">
|
|
789
|
+
<TransitionGroup
|
|
790
|
+
enter-active-class="transition-all duration-200 ease-out"
|
|
791
|
+
enter-from-class="scale-90 opacity-0"
|
|
792
|
+
enter-to-class="scale-100 opacity-100"
|
|
793
|
+
leave-active-class="transition-all duration-150 ease-in absolute"
|
|
794
|
+
leave-from-class="scale-100 opacity-100"
|
|
795
|
+
leave-to-class="scale-90 opacity-0"
|
|
796
|
+
tag="div"
|
|
797
|
+
class="flex flex-wrap gap-1"
|
|
798
|
+
>
|
|
799
|
+
<slot
|
|
800
|
+
v-for="option in visibleTags"
|
|
801
|
+
:key="option.value"
|
|
802
|
+
name="tag"
|
|
803
|
+
:option="option"
|
|
804
|
+
:remove="() => removeTag(option.value)"
|
|
805
|
+
>
|
|
806
|
+
<span
|
|
807
|
+
class="inline-flex items-center gap-1 rounded-md transition-colors"
|
|
808
|
+
:class="sizeConfig.tag"
|
|
809
|
+
:style="{
|
|
810
|
+
backgroundColor: `color-mix(in srgb, ${resolvedColor} 15%, transparent)`,
|
|
811
|
+
color: resolvedColor
|
|
812
|
+
}"
|
|
813
|
+
>
|
|
814
|
+
<span class="truncate max-w-24">{{ option.label ?? option.value }}</span>
|
|
815
|
+
<button
|
|
816
|
+
type="button"
|
|
817
|
+
class="mdi mdi-close text-xs opacity-70 hover:opacity-100 transition-opacity"
|
|
818
|
+
@click="removeTag(option.value, $event)"
|
|
819
|
+
/>
|
|
820
|
+
</span>
|
|
821
|
+
</slot>
|
|
822
|
+
<span
|
|
823
|
+
v-if="hiddenTagCount > 0"
|
|
824
|
+
:key="'more'"
|
|
825
|
+
class="text-muted-foreground"
|
|
826
|
+
:class="sizeConfig.tag"
|
|
827
|
+
>
|
|
828
|
+
+{{ hiddenTagCount }}
|
|
829
|
+
</span>
|
|
830
|
+
</TransitionGroup>
|
|
831
|
+
|
|
832
|
+
<!-- Inline filter input for multiple mode -->
|
|
833
|
+
<input
|
|
834
|
+
v-if="filter"
|
|
835
|
+
ref="filterInputRef"
|
|
836
|
+
type="text"
|
|
837
|
+
:value="filterQuery"
|
|
838
|
+
:placeholder="visibleTags.length === 0 ? (labelPlaceholder || placeholder) : ''"
|
|
839
|
+
:disabled="disabled || loading"
|
|
840
|
+
class="s-filter-input flex-1 min-w-[60px] bg-transparent outline-none text-foreground placeholder:text-muted-foreground text-sm"
|
|
841
|
+
@input="handleFilterInput"
|
|
842
|
+
@focus="handleFilterFocus"
|
|
843
|
+
@blur="handleFilterBlur"
|
|
844
|
+
@keydown="handleKeydown"
|
|
845
|
+
@click.stop="!isOpen && open()"
|
|
846
|
+
/>
|
|
847
|
+
</template>
|
|
848
|
+
|
|
849
|
+
<!-- Multiple with no tags but filter enabled -->
|
|
850
|
+
<template v-else-if="multiple && filter && visibleTags.length === 0">
|
|
851
|
+
<input
|
|
852
|
+
ref="filterInputRef"
|
|
853
|
+
type="text"
|
|
854
|
+
:value="filterQuery"
|
|
855
|
+
:placeholder="labelPlaceholder || placeholder"
|
|
856
|
+
:disabled="disabled || loading"
|
|
857
|
+
class="s-filter-input flex-1 min-w-0 bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
|
|
858
|
+
@input="handleFilterInput"
|
|
859
|
+
@focus="handleFilterFocus"
|
|
860
|
+
@blur="handleFilterBlur"
|
|
861
|
+
@keydown="handleKeydown"
|
|
862
|
+
@click.stop="!isOpen && open()"
|
|
863
|
+
/>
|
|
864
|
+
</template>
|
|
865
|
+
|
|
866
|
+
<!-- Single: Display value -->
|
|
867
|
+
<template v-else-if="hasValue && !multiple">
|
|
868
|
+
<slot name="selected" :option="selectedOptions[0]">
|
|
869
|
+
<img
|
|
870
|
+
v-if="selectedOptions[0]?.image"
|
|
871
|
+
:src="selectedOptions[0].image"
|
|
872
|
+
:alt="selectedOptions[0].label"
|
|
873
|
+
class="w-5 h-5 rounded-full object-cover shrink-0"
|
|
874
|
+
/>
|
|
875
|
+
<span v-else-if="selectedOptions[0]?.icon" :class="['mdi', `mdi-${selectedOptions[0].icon}`, 'text-muted-foreground']" />
|
|
876
|
+
<span class="truncate text-foreground">{{ displayValue }}</span>
|
|
877
|
+
</slot>
|
|
878
|
+
</template>
|
|
879
|
+
|
|
880
|
+
<!-- Placeholder (only show if not using labelPlaceholder) -->
|
|
881
|
+
<span v-else-if="!labelPlaceholder" class="text-muted-foreground truncate">
|
|
882
|
+
{{ placeholder }}
|
|
883
|
+
</span>
|
|
884
|
+
|
|
885
|
+
<!-- Empty space when using labelPlaceholder without value -->
|
|
886
|
+
<span v-else class="opacity-0">{{ placeholder }}</span>
|
|
887
|
+
</div>
|
|
888
|
+
|
|
889
|
+
<!-- Suffix slot -->
|
|
890
|
+
<slot name="suffix" />
|
|
891
|
+
|
|
892
|
+
<!-- Loading spinner -->
|
|
893
|
+
<span
|
|
894
|
+
v-if="loading"
|
|
895
|
+
class="mdi mdi-loading animate-spin text-muted-foreground"
|
|
896
|
+
:class="sizeConfig.icon"
|
|
897
|
+
/>
|
|
898
|
+
|
|
899
|
+
<!-- Clear button -->
|
|
900
|
+
<button
|
|
901
|
+
v-else-if="clearable && hasValue && !disabled"
|
|
902
|
+
type="button"
|
|
903
|
+
class="mdi mdi-close-circle text-muted-foreground hover:text-muted-foreground transition-colors shrink-0"
|
|
904
|
+
:class="sizeConfig.icon"
|
|
905
|
+
@click="clear"
|
|
906
|
+
/>
|
|
907
|
+
|
|
908
|
+
<!-- Dropdown arrow -->
|
|
909
|
+
<slot name="arrow" :is-open="isOpen">
|
|
910
|
+
<span
|
|
911
|
+
v-if="!loading"
|
|
912
|
+
class="text-muted-foreground transition-transform duration-200 shrink-0"
|
|
913
|
+
:class="['mdi', `mdi-${arrowIcon}`, sizeConfig.icon, { 'rotate-180': isOpen }]"
|
|
914
|
+
/>
|
|
915
|
+
</slot>
|
|
916
|
+
</div>
|
|
917
|
+
|
|
918
|
+
<!-- Error / Hint -->
|
|
919
|
+
<p
|
|
920
|
+
v-if="error"
|
|
921
|
+
class="mt-1.5 text-xs text-red-500 flex items-center gap-1"
|
|
922
|
+
>
|
|
923
|
+
<span class="mdi mdi-alert-circle" />
|
|
924
|
+
{{ error }}
|
|
925
|
+
</p>
|
|
926
|
+
<p
|
|
927
|
+
v-else-if="hint"
|
|
928
|
+
class="mt-1.5 text-xs text-muted-foreground"
|
|
929
|
+
>
|
|
930
|
+
{{ hint }}
|
|
931
|
+
</p>
|
|
932
|
+
|
|
933
|
+
<!-- Dropdown -->
|
|
934
|
+
<Teleport v-if="teleportTarget" :to="teleportTarget" :disabled="!teleportTarget">
|
|
935
|
+
<Transition
|
|
936
|
+
enter-active-class="transition-all duration-200 ease-out"
|
|
937
|
+
:enter-from-class="dropdownPosition.placement === 'top' ? 'opacity-0 translate-y-2' : 'opacity-0 -translate-y-2'"
|
|
938
|
+
enter-to-class="opacity-100 translate-y-0"
|
|
939
|
+
leave-active-class="transition-all duration-150 ease-in"
|
|
940
|
+
leave-from-class="opacity-100 translate-y-0"
|
|
941
|
+
:leave-to-class="dropdownPosition.placement === 'top' ? 'opacity-0 translate-y-2' : 'opacity-0 -translate-y-2'"
|
|
942
|
+
>
|
|
943
|
+
<div
|
|
944
|
+
v-if="isOpen"
|
|
945
|
+
ref="dropdownRef"
|
|
946
|
+
role="listbox"
|
|
947
|
+
:aria-multiselectable="multiple"
|
|
948
|
+
class="s-select-dropdown fixed z-[100] overflow-hidden border border-border shadow-xl"
|
|
949
|
+
:class="dropdownRoundedConfig"
|
|
950
|
+
:style="{
|
|
951
|
+
top: dropdownPosition.top ? `${dropdownPosition.top}px` : 'auto',
|
|
952
|
+
bottom: dropdownPosition.bottom ? `${dropdownPosition.bottom}px` : 'auto',
|
|
953
|
+
left: `${dropdownPosition.left}px`,
|
|
954
|
+
width: menuWidth ? (typeof menuWidth === 'number' ? `${menuWidth}px` : menuWidth) : `${dropdownPosition.width}px`,
|
|
955
|
+
maxHeight: maxHeight
|
|
956
|
+
}"
|
|
957
|
+
>
|
|
958
|
+
<!-- Glassmorphism background -->
|
|
959
|
+
<div class="absolute inset-0 bg-background/95 backdrop-blur-xl" />
|
|
960
|
+
|
|
961
|
+
<!-- Content -->
|
|
962
|
+
<div class="relative">
|
|
963
|
+
<!-- Search input -->
|
|
964
|
+
<div v-if="searchable" class="p-2 border-b border-border">
|
|
965
|
+
<div class="relative">
|
|
966
|
+
<span class="absolute left-3 top-1/2 -translate-y-1/2 mdi mdi-magnify text-muted-foreground" />
|
|
967
|
+
<input
|
|
968
|
+
ref="searchInputRef"
|
|
969
|
+
type="text"
|
|
970
|
+
:value="searchQuery"
|
|
971
|
+
placeholder="Search..."
|
|
972
|
+
class="w-full pl-9 pr-3 py-2 text-sm bg-muted border border-border rounded-lg outline-none focus:border-primary focus:ring-2 focus:ring-ring/20 transition-all text-foreground placeholder:text-muted-foreground"
|
|
973
|
+
@input="handleSearchInput"
|
|
974
|
+
@keydown="handleKeydown"
|
|
975
|
+
/>
|
|
976
|
+
</div>
|
|
977
|
+
</div>
|
|
978
|
+
|
|
979
|
+
<!-- Header slot -->
|
|
980
|
+
<slot name="header" />
|
|
981
|
+
|
|
982
|
+
<!-- Options list -->
|
|
983
|
+
<div
|
|
984
|
+
class="overflow-y-auto overscroll-contain py-0.5"
|
|
985
|
+
:style="{ maxHeight: searchable ? `calc(${maxHeight} - 60px)` : maxHeight }"
|
|
986
|
+
>
|
|
987
|
+
<!-- Using options prop -->
|
|
988
|
+
<template v-if="options.length > 0">
|
|
989
|
+
<template v-if="filteredOptions.length > 0 || showCreateOption">
|
|
990
|
+
<!-- Grouped options rendering -->
|
|
991
|
+
<template v-if="hasGroups">
|
|
992
|
+
<template v-for="[groupName, groupOptions] in groupedOptions" :key="groupName ?? 'ungrouped'">
|
|
993
|
+
<!-- Group header -->
|
|
994
|
+
<div
|
|
995
|
+
v-if="groupName"
|
|
996
|
+
class="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground sticky top-0 bg-background/95 backdrop-blur-sm mt-2 first:mt-0 border-t border-border first:border-t-0 z-20"
|
|
997
|
+
>
|
|
998
|
+
{{ groupName }}
|
|
999
|
+
</div>
|
|
1000
|
+
<!-- Group options -->
|
|
1001
|
+
<div
|
|
1002
|
+
v-for="option in groupOptions"
|
|
1003
|
+
:key="option.value"
|
|
1004
|
+
role="option"
|
|
1005
|
+
:aria-selected="isSelected(option.value)"
|
|
1006
|
+
:aria-disabled="option.disabled"
|
|
1007
|
+
class="s-option relative flex items-center cursor-pointer transition-all duration-150 select-none"
|
|
1008
|
+
:class="[
|
|
1009
|
+
sizeConfig.option,
|
|
1010
|
+
{
|
|
1011
|
+
'opacity-50 cursor-not-allowed': option.disabled,
|
|
1012
|
+
'text-foreground': isSelected(option.value),
|
|
1013
|
+
'text-muted-foreground hover:text-foreground hover:bg-accent/50': !isSelected(option.value) && !option.disabled
|
|
1014
|
+
}
|
|
1015
|
+
]"
|
|
1016
|
+
@click="!option.disabled && selectOption(option.value)"
|
|
1017
|
+
>
|
|
1018
|
+
<!-- Highlight background for selected -->
|
|
1019
|
+
<div
|
|
1020
|
+
v-if="isSelected(option.value)"
|
|
1021
|
+
class="absolute inset-0 transition-all duration-150 rounded-lg mx-1"
|
|
1022
|
+
:style="{ backgroundColor: `color-mix(in srgb, ${option.color ?? resolvedColor} 15%, transparent)` }"
|
|
1023
|
+
/>
|
|
1024
|
+
|
|
1025
|
+
<!-- Image -->
|
|
1026
|
+
<img
|
|
1027
|
+
v-if="option.image"
|
|
1028
|
+
:src="option.image"
|
|
1029
|
+
:alt="option.label"
|
|
1030
|
+
class="relative z-10 w-6 h-6 rounded-full object-cover shrink-0 mr-2.5"
|
|
1031
|
+
/>
|
|
1032
|
+
<!-- Icon -->
|
|
1033
|
+
<span
|
|
1034
|
+
v-else-if="option.icon"
|
|
1035
|
+
class="relative z-10 shrink-0 mr-2.5"
|
|
1036
|
+
:class="['mdi', `mdi-${option.icon}`, sizeConfig.icon]"
|
|
1037
|
+
:style="isSelected(option.value) ? { color: option.color ?? resolvedColor } : {}"
|
|
1038
|
+
/>
|
|
1039
|
+
|
|
1040
|
+
<!-- Content -->
|
|
1041
|
+
<div class="relative z-10 flex-1 min-w-0">
|
|
1042
|
+
<slot name="option" :option="option" :selected="isSelected(option.value)">
|
|
1043
|
+
<span class="truncate block">{{ option.label ?? option.value }}</span>
|
|
1044
|
+
<p v-if="option.description" class="text-xs text-muted-foreground truncate mt-0.5">
|
|
1045
|
+
{{ option.description }}
|
|
1046
|
+
</p>
|
|
1047
|
+
</slot>
|
|
1048
|
+
</div>
|
|
1049
|
+
|
|
1050
|
+
<!-- Check mark -->
|
|
1051
|
+
<span
|
|
1052
|
+
v-if="isSelected(option.value)"
|
|
1053
|
+
class="relative z-10 mdi mdi-check shrink-0 ml-2"
|
|
1054
|
+
:class="sizeConfig.icon"
|
|
1055
|
+
:style="{ color: option.color ?? resolvedColor }"
|
|
1056
|
+
/>
|
|
1057
|
+
</div>
|
|
1058
|
+
</template>
|
|
1059
|
+
</template>
|
|
1060
|
+
|
|
1061
|
+
<!-- Non-grouped options (flat list) -->
|
|
1062
|
+
<template v-else>
|
|
1063
|
+
<div
|
|
1064
|
+
v-for="(option, index) in filteredOptions"
|
|
1065
|
+
:key="option.value"
|
|
1066
|
+
role="option"
|
|
1067
|
+
:aria-selected="isSelected(option.value)"
|
|
1068
|
+
:aria-disabled="option.disabled"
|
|
1069
|
+
class="s-option relative flex items-center cursor-pointer transition-all duration-150 select-none"
|
|
1070
|
+
:class="[
|
|
1071
|
+
sizeConfig.option,
|
|
1072
|
+
{
|
|
1073
|
+
'opacity-50 cursor-not-allowed': option.disabled,
|
|
1074
|
+
'text-foreground': highlightedIndex === index || isSelected(option.value),
|
|
1075
|
+
'text-muted-foreground hover:text-foreground': highlightedIndex !== index && !isSelected(option.value) && !option.disabled
|
|
1076
|
+
}
|
|
1077
|
+
]"
|
|
1078
|
+
@click="!option.disabled && selectOption(option.value)"
|
|
1079
|
+
@mouseenter="highlightedIndex = index"
|
|
1080
|
+
>
|
|
1081
|
+
<!-- Highlight background -->
|
|
1082
|
+
<div
|
|
1083
|
+
v-if="highlightedIndex === index || isSelected(option.value)"
|
|
1084
|
+
class="absolute inset-0 transition-all duration-150 rounded-lg mx-1"
|
|
1085
|
+
:class="isSelected(option.value) ? 'opacity-100' : 'opacity-60'"
|
|
1086
|
+
:style="{
|
|
1087
|
+
backgroundColor: isSelected(option.value)
|
|
1088
|
+
? `color-mix(in srgb, ${option.color ?? resolvedColor} 15%, transparent)`
|
|
1089
|
+
: 'var(--s-accent)'
|
|
1090
|
+
}"
|
|
1091
|
+
/>
|
|
1092
|
+
|
|
1093
|
+
<!-- Image -->
|
|
1094
|
+
<img
|
|
1095
|
+
v-if="option.image"
|
|
1096
|
+
:src="option.image"
|
|
1097
|
+
:alt="option.label"
|
|
1098
|
+
class="relative z-10 w-6 h-6 rounded-full object-cover shrink-0 mr-2.5"
|
|
1099
|
+
/>
|
|
1100
|
+
<!-- Icon -->
|
|
1101
|
+
<span
|
|
1102
|
+
v-else-if="option.icon"
|
|
1103
|
+
class="relative z-10 shrink-0 mr-2.5"
|
|
1104
|
+
:class="['mdi', `mdi-${option.icon}`, sizeConfig.icon]"
|
|
1105
|
+
:style="isSelected(option.value) ? { color: option.color ?? resolvedColor } : {}"
|
|
1106
|
+
/>
|
|
1107
|
+
|
|
1108
|
+
<!-- Content -->
|
|
1109
|
+
<div class="relative z-10 flex-1 min-w-0">
|
|
1110
|
+
<slot name="option" :option="option" :selected="isSelected(option.value)" :highlighted="highlightedIndex === index">
|
|
1111
|
+
<span class="truncate block">{{ option.label ?? option.value }}</span>
|
|
1112
|
+
<p v-if="option.description" class="text-xs text-muted-foreground truncate mt-0.5">
|
|
1113
|
+
{{ option.description }}
|
|
1114
|
+
</p>
|
|
1115
|
+
</slot>
|
|
1116
|
+
</div>
|
|
1117
|
+
|
|
1118
|
+
<!-- Check mark -->
|
|
1119
|
+
<Transition
|
|
1120
|
+
enter-active-class="transition-all duration-150 ease-out"
|
|
1121
|
+
enter-from-class="scale-0 opacity-0"
|
|
1122
|
+
enter-to-class="scale-100 opacity-100"
|
|
1123
|
+
leave-active-class="transition-all duration-100 ease-in"
|
|
1124
|
+
leave-from-class="scale-100 opacity-100"
|
|
1125
|
+
leave-to-class="scale-0 opacity-0"
|
|
1126
|
+
>
|
|
1127
|
+
<span
|
|
1128
|
+
v-if="isSelected(option.value)"
|
|
1129
|
+
class="relative z-10 mdi mdi-check shrink-0 ml-2"
|
|
1130
|
+
:class="sizeConfig.icon"
|
|
1131
|
+
:style="{ color: option.color ?? resolvedColor }"
|
|
1132
|
+
/>
|
|
1133
|
+
</Transition>
|
|
1134
|
+
</div>
|
|
1135
|
+
</template>
|
|
1136
|
+
|
|
1137
|
+
<!-- Creatable option -->
|
|
1138
|
+
<div
|
|
1139
|
+
v-if="showCreateOption"
|
|
1140
|
+
class="s-option relative flex items-center cursor-pointer transition-all duration-150 select-none border-t border-border mt-1 pt-1"
|
|
1141
|
+
:class="sizeConfig.option"
|
|
1142
|
+
@click="createOption"
|
|
1143
|
+
>
|
|
1144
|
+
<span class="mdi mdi-plus-circle mr-2.5" :class="sizeConfig.icon" :style="{ color: resolvedColor }" />
|
|
1145
|
+
<span class="text-muted-foreground">{{ createOptionLabel }}</span>
|
|
1146
|
+
</div>
|
|
1147
|
+
</template>
|
|
1148
|
+
|
|
1149
|
+
<!-- No results -->
|
|
1150
|
+
<div v-else class="px-4 py-8 text-center">
|
|
1151
|
+
<slot name="empty">
|
|
1152
|
+
<span class="mdi mdi-magnify-close text-3xl text-muted-foreground mb-2 block" />
|
|
1153
|
+
<p class="text-sm text-muted-foreground">{{ noResultsText }}</p>
|
|
1154
|
+
</slot>
|
|
1155
|
+
</div>
|
|
1156
|
+
</template>
|
|
1157
|
+
|
|
1158
|
+
<!-- Using slots (SOption children) -->
|
|
1159
|
+
<template v-else-if="$slots.default">
|
|
1160
|
+
<slot />
|
|
1161
|
+
</template>
|
|
1162
|
+
|
|
1163
|
+
<!-- No options -->
|
|
1164
|
+
<div v-else class="px-4 py-8 text-center">
|
|
1165
|
+
<slot name="empty">
|
|
1166
|
+
<span class="mdi mdi-selection-off text-3xl text-muted-foreground mb-2 block" />
|
|
1167
|
+
<p class="text-sm text-muted-foreground">{{ noOptionsText }}</p>
|
|
1168
|
+
</slot>
|
|
1169
|
+
</div>
|
|
1170
|
+
|
|
1171
|
+
<!-- Loading state -->
|
|
1172
|
+
<div v-if="loading" class="px-4 py-8 text-center">
|
|
1173
|
+
<slot name="loading">
|
|
1174
|
+
<span class="mdi mdi-loading animate-spin text-2xl mb-2 block" :style="{ color: resolvedColor }" />
|
|
1175
|
+
<p class="text-sm text-muted-foreground">Loading...</p>
|
|
1176
|
+
</slot>
|
|
1177
|
+
</div>
|
|
1178
|
+
</div>
|
|
1179
|
+
|
|
1180
|
+
<!-- Footer slot -->
|
|
1181
|
+
<slot name="footer" />
|
|
1182
|
+
</div>
|
|
1183
|
+
</div>
|
|
1184
|
+
</Transition>
|
|
1185
|
+
</Teleport>
|
|
1186
|
+
|
|
1187
|
+
<!-- Non-teleported dropdown (fallback) -->
|
|
1188
|
+
<template v-else>
|
|
1189
|
+
<Transition
|
|
1190
|
+
enter-active-class="transition-all duration-200 ease-out"
|
|
1191
|
+
enter-from-class="opacity-0 -translate-y-2"
|
|
1192
|
+
enter-to-class="opacity-100 translate-y-0"
|
|
1193
|
+
leave-active-class="transition-all duration-150 ease-in"
|
|
1194
|
+
leave-from-class="opacity-100 translate-y-0"
|
|
1195
|
+
leave-to-class="opacity-0 -translate-y-2"
|
|
1196
|
+
>
|
|
1197
|
+
<div
|
|
1198
|
+
v-if="isOpen"
|
|
1199
|
+
ref="dropdownRef"
|
|
1200
|
+
role="listbox"
|
|
1201
|
+
class="s-select-dropdown absolute z-50 w-full mt-1 overflow-hidden border border-border shadow-xl bg-background"
|
|
1202
|
+
:class="roundedConfig"
|
|
1203
|
+
:style="{ maxHeight }"
|
|
1204
|
+
>
|
|
1205
|
+
<div class="overflow-y-auto overscroll-contain py-1" :style="{ maxHeight }">
|
|
1206
|
+
<slot />
|
|
1207
|
+
</div>
|
|
1208
|
+
</div>
|
|
1209
|
+
</Transition>
|
|
1210
|
+
</template>
|
|
1211
|
+
</div>
|
|
1212
|
+
</div>
|
|
1213
|
+
</template>
|
|
1214
|
+
|
|
1215
|
+
<style scoped>
|
|
1216
|
+
.s-select-trigger:focus-visible {
|
|
1217
|
+
outline: none;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/* Float label container - add padding top for label space */
|
|
1221
|
+
.s-select-trigger--float-label {
|
|
1222
|
+
margin-top: 0.5rem;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/* Float label positioning - starts centered vertically */
|
|
1226
|
+
.s-float-label {
|
|
1227
|
+
transform-origin: left center;
|
|
1228
|
+
will-change: transform, opacity, color;
|
|
1229
|
+
top: 50%;
|
|
1230
|
+
transform: translateY(-50%);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/* Float label active state - sits on top of border */
|
|
1234
|
+
.s-float-label--active {
|
|
1235
|
+
font-weight: 500;
|
|
1236
|
+
/* Position on top of the border */
|
|
1237
|
+
top: 0;
|
|
1238
|
+
transform: translateY(-50%);
|
|
1239
|
+
/* Background applied via inline style based on variant */
|
|
1240
|
+
border-radius: 2px;
|
|
1241
|
+
line-height: 1.2;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/* Elevation effect on focus/open (Vuesax-style lift) */
|
|
1245
|
+
.s-select-trigger--elevated {
|
|
1246
|
+
transform: translateY(-2px);
|
|
1247
|
+
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1), 0 4px 10px -5px rgba(0, 0, 0, 0.05);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/* When elevated with float label, also move label up slightly */
|
|
1251
|
+
.s-select-trigger--elevated.s-select-trigger--float-label .s-float-label--active {
|
|
1252
|
+
transform: translateY(calc(-50% - 2px));
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/* Smooth transition for trigger */
|
|
1256
|
+
.s-select-trigger {
|
|
1257
|
+
transition: all 0.25s ease;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/* Filter input styles */
|
|
1261
|
+
.s-filter-input {
|
|
1262
|
+
caret-color: var(--s-primary);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
.s-filter-input::selection {
|
|
1266
|
+
background-color: color-mix(in srgb, var(--s-primary) 30%, transparent);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
.s-select-dropdown {
|
|
1270
|
+
scrollbar-width: thin;
|
|
1271
|
+
scrollbar-color: var(--s-border) transparent;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
.s-select-dropdown::-webkit-scrollbar {
|
|
1275
|
+
width: 6px;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
.s-select-dropdown::-webkit-scrollbar-track {
|
|
1279
|
+
background: transparent;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
.s-select-dropdown::-webkit-scrollbar-thumb {
|
|
1283
|
+
background: var(--s-border);
|
|
1284
|
+
border-radius: 3px;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
.s-select-dropdown::-webkit-scrollbar-thumb:hover {
|
|
1288
|
+
background: var(--s-input);
|
|
1289
|
+
}
|
|
1290
|
+
</style>
|