From 07937e26dbb437916e8979f6159a2223b98d0ed0 Mon Sep 17 00:00:00 2001 From: robonen Date: Tue, 9 Jun 2026 13:54:52 +0700 Subject: [PATCH] feat(forms): add useMaskedField and useMaskedInput composables for input masking --- core/encoding/src/index.ts | 1 + core/encoding/src/luhn/index.test.ts | 21 + core/encoding/src/luhn/index.ts | 39 ++ core/platform/package.json | 1 + .../src/browsers/domStyle/index.test.ts | 105 +++ core/platform/src/browsers/domStyle/index.ts | 190 ++++++ core/platform/src/browsers/index.ts | 3 + .../src/browsers/inputState/index.test.ts | 55 ++ .../platform/src/browsers/inputState/index.ts | 80 +++ .../src/browsers/userAgent/index.test.ts | 63 ++ core/platform/src/browsers/userAgent/index.ts | 117 ++++ core/platform/src/multi/card/card-brands.ts | 51 ++ .../src/multi/card/find-card-brand.test.ts | 33 + .../src/multi/card/find-card-brand.ts | 85 +++ core/platform/src/multi/card/index.ts | 3 + core/platform/src/multi/card/validate.test.ts | 19 + core/platform/src/multi/card/validate.ts | 36 + core/platform/src/multi/index.ts | 2 + .../src/multi/intl/find-phone-country.test.ts | 30 + .../src/multi/intl/find-phone-country.ts | 109 +++ core/platform/src/multi/intl/flag.test.ts | 21 + core/platform/src/multi/intl/flag.ts | 34 + core/platform/src/multi/intl/index.ts | 3 + .../src/multi/intl/phone-countries.ts | 247 +++++++ docs/app/components/DocsComponentAnatomy.vue | 5 +- docs/app/components/DocsDemo.vue | 14 +- docs/app/components/DocsMethodsList.vue | 4 +- docs/app/components/DocsParamsTable.vue | 3 +- docs/app/components/DocsPropsTable.vue | 3 +- docs/app/components/DocsText.vue | 33 + docs/app/composables/useMarkdown.ts | 15 + docs/app/pages/[package]/[utility].vue | 28 +- docs/modules/extractor/extract.ts | 323 ++++++++- docs/modules/extractor/index.ts | 59 +- docs/modules/extractor/types.ts | 7 + pnpm-lock.yaml | 6 + vue/primitives/package.json | 1 + .../src/accordion/AccordionContent.vue | 6 + .../src/accordion/AccordionItem.vue | 6 + .../src/accordion/AccordionRoot.vue | 44 +- .../src/accordion/AccordionTrigger.vue | 6 + vue/primitives/src/accordion/demo.vue | 66 ++ .../src/alert-dialog/AlertDialogAction.vue | 5 + .../src/alert-dialog/AlertDialogCancel.vue | 5 + .../src/alert-dialog/AlertDialogContent.vue | 6 + .../src/alert-dialog/AlertDialogRoot.vue | 10 + vue/primitives/src/alert-dialog/demo.vue | 86 +++ .../src/aspect-ratio/AspectRatio.vue | 10 +- ...o-computes-padding-bottom-from-ratio-1.png | Bin 0 -> 2082 bytes ...ment-absolutely-covering-the-wrapper-1.png | Bin 0 -> 2082 bytes ...Ratio-renders-with-default-1-1-ratio-1.png | Bin 0 -> 2082 bytes vue/primitives/src/aspect-ratio/demo.vue | 48 ++ vue/primitives/src/avatar/AvatarFallback.vue | 6 + vue/primitives/src/avatar/AvatarImage.vue | 10 +- vue/primitives/src/avatar/AvatarRoot.vue | 10 + vue/primitives/src/avatar/demo.vue | 48 ++ vue/primitives/src/calendar/CalendarCell.vue | 6 + .../src/calendar/CalendarCellTrigger.vue | 6 + vue/primitives/src/calendar/CalendarGrid.vue | 5 + .../src/calendar/CalendarGridBody.vue | 4 + .../src/calendar/CalendarGridHead.vue | 4 + .../src/calendar/CalendarGridRow.vue | 4 + .../src/calendar/CalendarHeadCell.vue | 5 + .../src/calendar/CalendarHeader.vue | 4 + .../src/calendar/CalendarHeading.vue | 6 + vue/primitives/src/calendar/CalendarNext.vue | 5 + vue/primitives/src/calendar/CalendarPrev.vue | 5 + vue/primitives/src/calendar/CalendarRoot.vue | 11 + vue/primitives/src/calendar/demo.vue | 110 +++ .../src/checkbox/CheckboxIndicator.vue | 5 + vue/primitives/src/checkbox/CheckboxRoot.vue | 58 +- vue/primitives/src/checkbox/demo.vue | 100 +++ .../src/collapsible/CollapsibleContent.vue | 5 + .../src/collapsible/CollapsibleRoot.vue | 8 + .../src/collapsible/CollapsibleTrigger.vue | 5 + vue/primitives/src/collapsible/demo.vue | 59 ++ .../src/combobox/ComboboxAnchor.vue | 4 + vue/primitives/src/combobox/ComboboxArrow.vue | 4 + .../src/combobox/ComboboxCancel.vue | 4 + .../src/combobox/ComboboxContent.vue | 4 + .../src/combobox/ComboboxContentImpl.vue | 4 + vue/primitives/src/combobox/ComboboxEmpty.vue | 4 + vue/primitives/src/combobox/ComboboxGroup.vue | 4 + vue/primitives/src/combobox/ComboboxInput.vue | 4 + vue/primitives/src/combobox/ComboboxItem.vue | 4 + .../src/combobox/ComboboxItemIndicator.vue | 4 + vue/primitives/src/combobox/ComboboxLabel.vue | 4 + .../src/combobox/ComboboxPortal.vue | 4 + vue/primitives/src/combobox/ComboboxRoot.vue | 10 +- .../src/combobox/ComboboxSeparator.vue | 4 + .../src/combobox/ComboboxTrigger.vue | 4 + .../src/combobox/ComboboxViewport.vue | 4 + vue/primitives/src/combobox/demo.vue | 119 ++++ vue/primitives/src/command/CommandEmpty.vue | 5 + vue/primitives/src/command/CommandGroup.vue | 5 + vue/primitives/src/command/CommandInput.vue | 5 + vue/primitives/src/command/CommandItem.vue | 5 + vue/primitives/src/command/CommandList.vue | 5 + vue/primitives/src/command/CommandLoading.vue | 5 + vue/primitives/src/command/CommandRoot.vue | 13 +- .../src/command/CommandSeparator.vue | 4 + vue/primitives/src/command/demo.vue | 134 ++++ .../src/context-menu/ContextMenuArrow.vue | 4 + .../context-menu/ContextMenuCheckboxItem.vue | 5 + .../src/context-menu/ContextMenuContent.vue | 5 + .../src/context-menu/ContextMenuGroup.vue | 4 + .../src/context-menu/ContextMenuItem.vue | 4 + .../context-menu/ContextMenuItemIndicator.vue | 5 + .../src/context-menu/ContextMenuLabel.vue | 4 + .../src/context-menu/ContextMenuPortal.vue | 5 + .../context-menu/ContextMenuRadioGroup.vue | 4 + .../src/context-menu/ContextMenuRadioItem.vue | 5 + .../src/context-menu/ContextMenuRoot.vue | 27 +- .../src/context-menu/ContextMenuSeparator.vue | 4 + .../src/context-menu/ContextMenuSub.vue | 5 + .../context-menu/ContextMenuSubContent.vue | 4 + .../context-menu/ContextMenuSubTrigger.vue | 4 + .../src/context-menu/ContextMenuTrigger.vue | 5 + vue/primitives/src/context-menu/demo.vue | 183 +++++ vue/primitives/src/context-menu/index.ts | 2 +- .../src/date-picker/DatePickerAnchor.vue | 5 + .../src/date-picker/DatePickerArrow.vue | 4 + .../src/date-picker/DatePickerCalendar.vue | 6 + .../src/date-picker/DatePickerClose.vue | 4 + .../src/date-picker/DatePickerContent.vue | 6 + .../src/date-picker/DatePickerField.vue | 5 + .../src/date-picker/DatePickerPortal.vue | 6 + .../src/date-picker/DatePickerRoot.vue | 7 + .../src/date-picker/DatePickerTrigger.vue | 5 + vue/primitives/src/date-picker/demo.vue | 158 +++++ vue/primitives/src/dialog/DialogClose.vue | 4 + vue/primitives/src/dialog/DialogContent.vue | 7 + .../src/dialog/DialogContentImpl.vue | 5 + .../src/dialog/DialogDescription.vue | 4 + vue/primitives/src/dialog/DialogOverlay.vue | 5 + vue/primitives/src/dialog/DialogPortal.vue | 5 + vue/primitives/src/dialog/DialogRoot.vue | 12 + vue/primitives/src/dialog/DialogTitle.vue | 5 + vue/primitives/src/dialog/DialogTrigger.vue | 5 + vue/primitives/src/dialog/demo.vue | 125 ++++ .../dismissable-layer/DismissableLayer.vue | 8 + vue/primitives/src/drawer/DrawerContent.vue | 120 ++++ vue/primitives/src/drawer/DrawerHandle.vue | 123 ++++ vue/primitives/src/drawer/DrawerOverlay.vue | 37 + vue/primitives/src/drawer/DrawerRoot.vue | 115 ++++ .../src/drawer/DrawerRootNested.vue | 54 ++ .../src/drawer/__test__/Drawer.test.ts | 221 ++++++ vue/primitives/src/drawer/constants.ts | 26 + vue/primitives/src/drawer/context.ts | 83 +++ vue/primitives/src/drawer/controls.ts | 632 ++++++++++++++++++ vue/primitives/src/drawer/demo.vue | 94 +++ vue/primitives/src/drawer/helpers.ts | 27 + vue/primitives/src/drawer/index.ts | 31 + vue/primitives/src/drawer/style.ts | 270 ++++++++ vue/primitives/src/drawer/types.ts | 13 + vue/primitives/src/drawer/usePositionFixed.ts | 127 ++++ .../src/drawer/useScaleBackground.ts | 64 ++ vue/primitives/src/drawer/useSnapPoints.ts | 283 ++++++++ .../src/dropdown-menu/DropdownMenuArrow.vue | 4 + .../DropdownMenuCheckboxItem.vue | 5 + .../src/dropdown-menu/DropdownMenuContent.vue | 5 + .../src/dropdown-menu/DropdownMenuGroup.vue | 4 + .../src/dropdown-menu/DropdownMenuItem.vue | 4 + .../DropdownMenuItemIndicator.vue | 4 + .../src/dropdown-menu/DropdownMenuLabel.vue | 4 + .../src/dropdown-menu/DropdownMenuPortal.vue | 4 + .../dropdown-menu/DropdownMenuRadioGroup.vue | 4 + .../dropdown-menu/DropdownMenuRadioItem.vue | 5 + .../src/dropdown-menu/DropdownMenuRoot.vue | 35 +- .../dropdown-menu/DropdownMenuSeparator.vue | 4 + .../src/dropdown-menu/DropdownMenuSub.vue | 5 + .../dropdown-menu/DropdownMenuSubContent.vue | 4 + .../dropdown-menu/DropdownMenuSubTrigger.vue | 5 + .../src/dropdown-menu/DropdownMenuTrigger.vue | 6 + vue/primitives/src/dropdown-menu/demo.vue | 131 ++++ vue/primitives/src/dropdown-menu/index.ts | 2 +- vue/primitives/src/editable/EditableArea.vue | 5 + .../src/editable/EditableCancelTrigger.vue | 4 + .../src/editable/EditableEditTrigger.vue | 4 + vue/primitives/src/editable/EditableInput.vue | 5 + .../src/editable/EditablePreview.vue | 5 + vue/primitives/src/editable/EditableRoot.vue | 44 +- .../src/editable/EditableSubmitTrigger.vue | 4 + vue/primitives/src/editable/demo.vue | 60 ++ vue/primitives/src/focus-scope/FocusScope.vue | 11 + .../src/hover-card/HoverCardArrow.vue | 5 + .../src/hover-card/HoverCardContent.vue | 5 + .../src/hover-card/HoverCardContentImpl.vue | 6 + .../src/hover-card/HoverCardPortal.vue | 5 + .../src/hover-card/HoverCardRoot.vue | 12 + .../src/hover-card/HoverCardTrigger.vue | 5 + vue/primitives/src/hover-card/demo.vue | 62 ++ vue/primitives/src/index.ts | 3 + vue/primitives/src/label/Label.vue | 11 +- vue/primitives/src/label/demo.vue | 55 ++ vue/primitives/src/listbox/ListboxContent.vue | 5 + vue/primitives/src/listbox/ListboxFilter.vue | 7 + vue/primitives/src/listbox/ListboxGroup.vue | 4 + .../src/listbox/ListboxGroupLabel.vue | 4 + vue/primitives/src/listbox/ListboxItem.vue | 5 + .../src/listbox/ListboxItemIndicator.vue | 4 + vue/primitives/src/listbox/ListboxRoot.vue | 23 +- vue/primitives/src/listbox/demo.vue | 106 +++ vue/primitives/src/menu/MenuAnchor.vue | 6 + vue/primitives/src/menu/MenuArrow.vue | 5 + vue/primitives/src/menu/MenuCheckboxItem.vue | 8 + vue/primitives/src/menu/MenuContent.vue | 10 + vue/primitives/src/menu/MenuContentImpl.vue | 9 + vue/primitives/src/menu/MenuGroup.vue | 4 + vue/primitives/src/menu/MenuItem.vue | 5 + vue/primitives/src/menu/MenuItemImpl.vue | 8 + vue/primitives/src/menu/MenuItemIndicator.vue | 6 + vue/primitives/src/menu/MenuLabel.vue | 5 + vue/primitives/src/menu/MenuPortal.vue | 5 + vue/primitives/src/menu/MenuRadioGroup.vue | 7 + vue/primitives/src/menu/MenuRadioItem.vue | 7 + vue/primitives/src/menu/MenuRoot.vue | 26 +- .../src/menu/MenuRootContentModal.vue | 3 + .../src/menu/MenuRootContentNonModal.vue | 4 + vue/primitives/src/menu/MenuSeparator.vue | 4 + vue/primitives/src/menu/MenuSub.vue | 7 + vue/primitives/src/menu/MenuSubContent.vue | 7 + vue/primitives/src/menu/MenuSubTrigger.vue | 6 + vue/primitives/src/menu/index.ts | 2 +- vue/primitives/src/menubar/MenubarArrow.vue | 4 + .../src/menubar/MenubarCheckboxItem.vue | 5 + vue/primitives/src/menubar/MenubarContent.vue | 6 + vue/primitives/src/menubar/MenubarGroup.vue | 4 + vue/primitives/src/menubar/MenubarItem.vue | 4 + .../src/menubar/MenubarItemIndicator.vue | 4 + vue/primitives/src/menubar/MenubarLabel.vue | 4 + vue/primitives/src/menubar/MenubarMenu.vue | 5 + vue/primitives/src/menubar/MenubarPortal.vue | 4 + .../src/menubar/MenubarRadioGroup.vue | 4 + .../src/menubar/MenubarRadioItem.vue | 5 + vue/primitives/src/menubar/MenubarRoot.vue | 39 +- .../src/menubar/MenubarSeparator.vue | 4 + vue/primitives/src/menubar/MenubarSub.vue | 5 + .../src/menubar/MenubarSubContent.vue | 4 + .../src/menubar/MenubarSubTrigger.vue | 5 + vue/primitives/src/menubar/MenubarTrigger.vue | 6 + vue/primitives/src/menubar/demo.vue | 162 +++++ vue/primitives/src/menubar/index.ts | 2 +- .../navigation-menu/NavigationMenuContent.vue | 6 + .../NavigationMenuContentImpl.vue | 6 + .../NavigationMenuIndicator.vue | 5 + .../navigation-menu/NavigationMenuItem.vue | 5 + .../navigation-menu/NavigationMenuLink.vue | 5 + .../navigation-menu/NavigationMenuList.vue | 6 + .../navigation-menu/NavigationMenuRoot.vue | 8 + .../src/navigation-menu/NavigationMenuSub.vue | 6 + .../navigation-menu/NavigationMenuTrigger.vue | 6 + .../NavigationMenuViewport.vue | 6 + vue/primitives/src/navigation-menu/demo.vue | 113 ++++ .../src/number-field/NumberFieldDecrement.vue | 7 + .../src/number-field/NumberFieldIncrement.vue | 7 + .../src/number-field/NumberFieldInput.vue | 7 + .../src/number-field/NumberFieldRoot.vue | 26 +- ...--ArrowDown-step--clamped-by-min-max-1.png | Bin 0 -> 2790 bytes ...ome-End-jump-to-min-max-when-defined-1.png | Bin 0 -> 3904 bytes ...berField-PageUp-PageDown-step-by-10--1.png | Bin 0 -> 3349 bytes ...typing-updates-value--invalid---null-1.png | Bin 0 -> 4398 bytes vue/primitives/src/number-field/demo.vue | 67 ++ .../src/pagination/PaginationEllipsis.vue | 9 +- .../src/pagination/PaginationFirst.vue | 9 +- .../src/pagination/PaginationLast.vue | 9 +- .../src/pagination/PaginationList.vue | 10 +- .../src/pagination/PaginationListItem.vue | 10 +- .../src/pagination/PaginationNext.vue | 9 +- .../src/pagination/PaginationPrev.vue | 9 +- .../src/pagination/PaginationRoot.vue | 27 +- vue/primitives/src/pagination/demo.vue | 104 +++ .../src/pin-input/PinInputInput.vue | 17 +- vue/primitives/src/pin-input/PinInputRoot.vue | 57 +- ...n-empty-moves-to-previous-and-clears-1.png | Bin 0 -> 3066 bytes ...renders-password-type-for-each-input-1.png | Bin 0 -> 3749 bytes .../PinInput-paste-fills-across-inputs-1.png | Bin 0 -> 3655 bytes ...-type-number-rejects-non-digit-input-1.png | Bin 0 -> 3381 bytes ...to-advances-focus-and-fires-complete-1.png | Bin 0 -> 3173 bytes vue/primitives/src/pin-input/demo.vue | 80 +++ vue/primitives/src/pin-input/index.ts | 2 + vue/primitives/src/popover/PopoverAnchor.vue | 5 + vue/primitives/src/popover/PopoverArrow.vue | 5 + vue/primitives/src/popover/PopoverClose.vue | 4 + vue/primitives/src/popover/PopoverContent.vue | 7 + .../src/popover/PopoverContentImpl.vue | 6 + vue/primitives/src/popover/PopoverPortal.vue | 5 + vue/primitives/src/popover/PopoverRoot.vue | 13 + vue/primitives/src/popover/PopoverTrigger.vue | 5 + vue/primitives/src/popover/demo.vue | 99 +++ vue/primitives/src/popper/PopperAnchor.vue | 7 + vue/primitives/src/popper/PopperArrow.vue | 6 + vue/primitives/src/popper/PopperContent.vue | 12 + vue/primitives/src/popper/PopperRoot.vue | 16 + vue/primitives/src/popper/index.ts | 1 + vue/primitives/src/presence/Presence.vue | 16 + .../src/progress/ProgressIndicator.vue | 6 + vue/primitives/src/progress/ProgressRoot.vue | 12 + vue/primitives/src/progress/demo.vue | 72 ++ .../src/qr-code/QrCodeBackground.vue | 52 ++ vue/primitives/src/qr-code/QrCodeCells.vue | 76 +++ vue/primitives/src/qr-code/QrCodeLogo.vue | 94 +++ vue/primitives/src/qr-code/QrCodeMarker.vue | 76 +++ vue/primitives/src/qr-code/QrCodeMarkers.vue | 61 ++ vue/primitives/src/qr-code/QrCodeRoot.vue | 134 ++++ .../src/qr-code/__test__/QrCode.test.ts | 190 ++++++ ...and-knocks-out-the-modules-behind-it-1.png | Bin 0 -> 17164 bytes vue/primitives/src/qr-code/context.ts | 43 ++ vue/primitives/src/qr-code/demo.vue | 103 +++ vue/primitives/src/qr-code/index.ts | 35 + vue/primitives/src/qr-code/utils.ts | 244 +++++++ .../src/radio-group/RadioGroupIndicator.vue | 5 + .../src/radio-group/RadioGroupItem.vue | 6 + .../src/radio-group/RadioGroupRoot.vue | 9 + vue/primitives/src/radio-group/demo.vue | 52 ++ .../src/roving-focus/RovingFocusGroup.vue | 10 + .../src/roving-focus/RovingFocusItem.vue | 7 + .../src/scroll-area/ScrollAreaCorner.vue | 4 + .../src/scroll-area/ScrollAreaRoot.vue | 8 + .../src/scroll-area/ScrollAreaScrollbar.vue | 6 + .../src/scroll-area/ScrollAreaThumb.vue | 5 + .../src/scroll-area/ScrollAreaViewport.vue | 5 + vue/primitives/src/scroll-area/demo.vue | 74 ++ vue/primitives/src/select/SelectArrow.vue | 5 + vue/primitives/src/select/SelectContent.vue | 6 + .../src/select/SelectContentImpl.vue | 6 + vue/primitives/src/select/SelectGroup.vue | 5 + vue/primitives/src/select/SelectIcon.vue | 4 + vue/primitives/src/select/SelectItem.vue | 6 + .../src/select/SelectItemAlignedPosition.vue | 6 + .../src/select/SelectItemIndicator.vue | 4 + vue/primitives/src/select/SelectItemText.vue | 5 + vue/primitives/src/select/SelectLabel.vue | 5 + .../src/select/SelectPopperPosition.vue | 11 + vue/primitives/src/select/SelectPortal.vue | 4 + vue/primitives/src/select/SelectRoot.vue | 17 +- .../src/select/SelectScrollButtonImpl.vue | 6 + .../src/select/SelectScrollDownButton.vue | 5 + .../src/select/SelectScrollUpButton.vue | 5 + vue/primitives/src/select/SelectSeparator.vue | 4 + vue/primitives/src/select/SelectTrigger.vue | 6 + vue/primitives/src/select/SelectValue.vue | 5 + vue/primitives/src/select/SelectViewport.vue | 5 + vue/primitives/src/select/demo.vue | 100 +++ vue/primitives/src/separator/Separator.vue | 7 + vue/primitives/src/separator/demo.vue | 52 ++ vue/primitives/src/slider/SliderRange.vue | 7 + vue/primitives/src/slider/SliderRoot.vue | 34 +- vue/primitives/src/slider/SliderThumb.vue | 14 +- vue/primitives/src/slider/SliderTrack.vue | 7 + vue/primitives/src/slider/demo.vue | 83 +++ .../src/stepper/StepperDescription.vue | 4 + .../src/stepper/StepperIndicator.vue | 5 + vue/primitives/src/stepper/StepperItem.vue | 5 + vue/primitives/src/stepper/StepperRoot.vue | 40 +- .../src/stepper/StepperSeparator.vue | 5 + vue/primitives/src/stepper/StepperTitle.vue | 4 + vue/primitives/src/stepper/StepperTrigger.vue | 5 + vue/primitives/src/stepper/demo.vue | 113 ++++ vue/primitives/src/switch/Switch.vue | 12 + vue/primitives/src/switch/demo.vue | 89 +++ vue/primitives/src/tabs/TabsContent.vue | 5 + vue/primitives/src/tabs/TabsList.vue | 4 + vue/primitives/src/tabs/TabsRoot.vue | 10 + vue/primitives/src/tabs/TabsTrigger.vue | 5 + vue/primitives/src/tabs/demo.vue | 73 ++ .../src/tags-input/TagsInputClear.vue | 4 + .../src/tags-input/TagsInputInput.vue | 5 + .../src/tags-input/TagsInputItem.vue | 4 + .../src/tags-input/TagsInputItemDelete.vue | 4 + .../src/tags-input/TagsInputItemText.vue | 4 + .../src/tags-input/TagsInputRoot.vue | 20 +- vue/primitives/src/tags-input/demo.vue | 80 +++ vue/primitives/src/teleport/Teleport.vue | 7 + vue/primitives/src/toast/ToastAction.vue | 5 + vue/primitives/src/toast/ToastClose.vue | 4 + vue/primitives/src/toast/ToastDescription.vue | 4 + vue/primitives/src/toast/ToastProvider.vue | 26 +- vue/primitives/src/toast/ToastRoot.vue | 19 +- vue/primitives/src/toast/ToastTitle.vue | 4 + vue/primitives/src/toast/ToastViewport.vue | 5 + vue/primitives/src/toast/demo.vue | 110 +++ .../src/toggle-group/ToggleGroupItem.vue | 7 + .../src/toggle-group/ToggleGroupRoot.vue | 36 +- vue/primitives/src/toggle-group/demo.vue | 77 +++ vue/primitives/src/toggle/Toggle.vue | 10 + vue/primitives/src/toggle/demo.vue | 59 ++ vue/primitives/src/toolbar/ToolbarButton.vue | 7 + vue/primitives/src/toolbar/ToolbarRoot.vue | 10 + .../src/toolbar/ToolbarSeparator.vue | 7 + vue/primitives/src/toolbar/demo.vue | 84 +++ vue/primitives/src/tooltip/TooltipArrow.vue | 5 + vue/primitives/src/tooltip/TooltipContent.vue | 6 + .../src/tooltip/TooltipContentImpl.vue | 6 + vue/primitives/src/tooltip/TooltipPortal.vue | 5 + .../src/tooltip/TooltipProvider.vue | 7 + vue/primitives/src/tooltip/TooltipRoot.vue | 13 + vue/primitives/src/tooltip/TooltipTrigger.vue | 6 + vue/primitives/src/tooltip/demo.vue | 65 ++ vue/primitives/src/tree/TreeItem.vue | 7 + vue/primitives/src/tree/TreeRoot.vue | 39 +- vue/primitives/src/tree/demo.vue | 96 +++ vue/primitives/src/tree/index.ts | 2 +- .../src/visually-hidden/VisuallyHidden.vue | 15 +- vue/primitives/src/visually-hidden/demo.vue | 60 ++ vue/toolkit/src/composables/forms/index.ts | 3 + .../src/composables/forms/mask/conform.ts | 265 ++++++++ .../src/composables/forms/mask/index.test.ts | 247 +++++++ .../src/composables/forms/mask/index.ts | 40 ++ .../src/composables/forms/mask/model.ts | 225 +++++++ .../composables/forms/mask/presets/card.ts | 76 +++ .../composables/forms/mask/presets/date.ts | 82 +++ .../composables/forms/mask/presets/index.ts | 11 + .../composables/forms/mask/presets/number.ts | 237 +++++++ .../forms/mask/presets/phone-country.ts | 84 +++ .../composables/forms/mask/presets/phone.ts | 35 + .../forms/mask/presets/template.ts | 57 ++ .../src/composables/forms/mask/types.ts | 107 +++ .../composables/forms/useMaskedField/demo.vue | 74 ++ .../forms/useMaskedField/index.test.ts | 71 ++ .../composables/forms/useMaskedField/index.ts | 69 ++ .../composables/forms/useMaskedField/types.ts | 55 ++ .../composables/forms/useMaskedInput/demo.vue | 97 +++ .../forms/useMaskedInput/index.test.ts | 93 +++ .../composables/forms/useMaskedInput/index.ts | 185 +++++ .../composables/forms/useMaskedInput/types.ts | 88 +++ 426 files changed, 12981 insertions(+), 311 deletions(-) create mode 100644 core/encoding/src/luhn/index.test.ts create mode 100644 core/encoding/src/luhn/index.ts create mode 100644 core/platform/src/browsers/domStyle/index.test.ts create mode 100644 core/platform/src/browsers/domStyle/index.ts create mode 100644 core/platform/src/browsers/inputState/index.test.ts create mode 100644 core/platform/src/browsers/inputState/index.ts create mode 100644 core/platform/src/browsers/userAgent/index.test.ts create mode 100644 core/platform/src/browsers/userAgent/index.ts create mode 100644 core/platform/src/multi/card/card-brands.ts create mode 100644 core/platform/src/multi/card/find-card-brand.test.ts create mode 100644 core/platform/src/multi/card/find-card-brand.ts create mode 100644 core/platform/src/multi/card/index.ts create mode 100644 core/platform/src/multi/card/validate.test.ts create mode 100644 core/platform/src/multi/card/validate.ts create mode 100644 core/platform/src/multi/intl/find-phone-country.test.ts create mode 100644 core/platform/src/multi/intl/find-phone-country.ts create mode 100644 core/platform/src/multi/intl/flag.test.ts create mode 100644 core/platform/src/multi/intl/flag.ts create mode 100644 core/platform/src/multi/intl/index.ts create mode 100644 core/platform/src/multi/intl/phone-countries.ts create mode 100644 docs/app/components/DocsText.vue create mode 100644 vue/primitives/src/accordion/demo.vue create mode 100644 vue/primitives/src/alert-dialog/demo.vue create mode 100644 vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-computes-padding-bottom-from-ratio-1.png create mode 100644 vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-places-inner-element-absolutely-covering-the-wrapper-1.png create mode 100644 vue/primitives/src/aspect-ratio/__test__/__screenshots__/AspectRatio.test.ts/AspectRatio-renders-with-default-1-1-ratio-1.png create mode 100644 vue/primitives/src/aspect-ratio/demo.vue create mode 100644 vue/primitives/src/avatar/demo.vue create mode 100644 vue/primitives/src/calendar/demo.vue create mode 100644 vue/primitives/src/checkbox/demo.vue create mode 100644 vue/primitives/src/collapsible/demo.vue create mode 100644 vue/primitives/src/combobox/demo.vue create mode 100644 vue/primitives/src/command/demo.vue create mode 100644 vue/primitives/src/context-menu/demo.vue create mode 100644 vue/primitives/src/date-picker/demo.vue create mode 100644 vue/primitives/src/dialog/demo.vue create mode 100644 vue/primitives/src/drawer/DrawerContent.vue create mode 100644 vue/primitives/src/drawer/DrawerHandle.vue create mode 100644 vue/primitives/src/drawer/DrawerOverlay.vue create mode 100644 vue/primitives/src/drawer/DrawerRoot.vue create mode 100644 vue/primitives/src/drawer/DrawerRootNested.vue create mode 100644 vue/primitives/src/drawer/__test__/Drawer.test.ts create mode 100644 vue/primitives/src/drawer/constants.ts create mode 100644 vue/primitives/src/drawer/context.ts create mode 100644 vue/primitives/src/drawer/controls.ts create mode 100644 vue/primitives/src/drawer/demo.vue create mode 100644 vue/primitives/src/drawer/helpers.ts create mode 100644 vue/primitives/src/drawer/index.ts create mode 100644 vue/primitives/src/drawer/style.ts create mode 100644 vue/primitives/src/drawer/types.ts create mode 100644 vue/primitives/src/drawer/usePositionFixed.ts create mode 100644 vue/primitives/src/drawer/useScaleBackground.ts create mode 100644 vue/primitives/src/drawer/useSnapPoints.ts create mode 100644 vue/primitives/src/dropdown-menu/demo.vue create mode 100644 vue/primitives/src/editable/demo.vue create mode 100644 vue/primitives/src/hover-card/demo.vue create mode 100644 vue/primitives/src/label/demo.vue create mode 100644 vue/primitives/src/listbox/demo.vue create mode 100644 vue/primitives/src/menubar/demo.vue create mode 100644 vue/primitives/src/navigation-menu/demo.vue create mode 100644 vue/primitives/src/number-field/__test__/__screenshots__/NumberField.test.ts/NumberField-ArrowUp---ArrowDown-step--clamped-by-min-max-1.png create mode 100644 vue/primitives/src/number-field/__test__/__screenshots__/NumberField.test.ts/NumberField-Home-End-jump-to-min-max-when-defined-1.png create mode 100644 vue/primitives/src/number-field/__test__/__screenshots__/NumberField.test.ts/NumberField-PageUp-PageDown-step-by-10--1.png create mode 100644 vue/primitives/src/number-field/__test__/__screenshots__/NumberField.test.ts/NumberField-typing-updates-value--invalid---null-1.png create mode 100644 vue/primitives/src/number-field/demo.vue create mode 100644 vue/primitives/src/pagination/demo.vue create mode 100644 vue/primitives/src/pin-input/__test__/__screenshots__/PinInput.test.ts/PinInput-Backspace-on-empty-moves-to-previous-and-clears-1.png create mode 100644 vue/primitives/src/pin-input/__test__/__screenshots__/PinInput.test.ts/PinInput-mask-renders-password-type-for-each-input-1.png create mode 100644 vue/primitives/src/pin-input/__test__/__screenshots__/PinInput.test.ts/PinInput-paste-fills-across-inputs-1.png create mode 100644 vue/primitives/src/pin-input/__test__/__screenshots__/PinInput.test.ts/PinInput-type-number-rejects-non-digit-input-1.png create mode 100644 vue/primitives/src/pin-input/__test__/__screenshots__/PinInput.test.ts/PinInput-typing-auto-advances-focus-and-fires-complete-1.png create mode 100644 vue/primitives/src/pin-input/demo.vue create mode 100644 vue/primitives/src/popover/demo.vue create mode 100644 vue/primitives/src/progress/demo.vue create mode 100644 vue/primitives/src/qr-code/QrCodeBackground.vue create mode 100644 vue/primitives/src/qr-code/QrCodeCells.vue create mode 100644 vue/primitives/src/qr-code/QrCodeLogo.vue create mode 100644 vue/primitives/src/qr-code/QrCodeMarker.vue create mode 100644 vue/primitives/src/qr-code/QrCodeMarkers.vue create mode 100644 vue/primitives/src/qr-code/QrCodeRoot.vue create mode 100644 vue/primitives/src/qr-code/__test__/QrCode.test.ts create mode 100644 vue/primitives/src/qr-code/__test__/__screenshots__/QrCode.test.ts/QrCodeLogo-renders-an--image--for-src-and-knocks-out-the-modules-behind-it-1.png create mode 100644 vue/primitives/src/qr-code/context.ts create mode 100644 vue/primitives/src/qr-code/demo.vue create mode 100644 vue/primitives/src/qr-code/index.ts create mode 100644 vue/primitives/src/qr-code/utils.ts create mode 100644 vue/primitives/src/radio-group/demo.vue create mode 100644 vue/primitives/src/scroll-area/demo.vue create mode 100644 vue/primitives/src/select/demo.vue create mode 100644 vue/primitives/src/separator/demo.vue create mode 100644 vue/primitives/src/slider/demo.vue create mode 100644 vue/primitives/src/stepper/demo.vue create mode 100644 vue/primitives/src/switch/demo.vue create mode 100644 vue/primitives/src/tabs/demo.vue create mode 100644 vue/primitives/src/tags-input/demo.vue create mode 100644 vue/primitives/src/toast/demo.vue create mode 100644 vue/primitives/src/toggle-group/demo.vue create mode 100644 vue/primitives/src/toggle/demo.vue create mode 100644 vue/primitives/src/toolbar/demo.vue create mode 100644 vue/primitives/src/tooltip/demo.vue create mode 100644 vue/primitives/src/tree/demo.vue create mode 100644 vue/primitives/src/visually-hidden/demo.vue create mode 100644 vue/toolkit/src/composables/forms/mask/conform.ts create mode 100644 vue/toolkit/src/composables/forms/mask/index.test.ts create mode 100644 vue/toolkit/src/composables/forms/mask/index.ts create mode 100644 vue/toolkit/src/composables/forms/mask/model.ts create mode 100644 vue/toolkit/src/composables/forms/mask/presets/card.ts create mode 100644 vue/toolkit/src/composables/forms/mask/presets/date.ts create mode 100644 vue/toolkit/src/composables/forms/mask/presets/index.ts create mode 100644 vue/toolkit/src/composables/forms/mask/presets/number.ts create mode 100644 vue/toolkit/src/composables/forms/mask/presets/phone-country.ts create mode 100644 vue/toolkit/src/composables/forms/mask/presets/phone.ts create mode 100644 vue/toolkit/src/composables/forms/mask/presets/template.ts create mode 100644 vue/toolkit/src/composables/forms/mask/types.ts create mode 100644 vue/toolkit/src/composables/forms/useMaskedField/demo.vue create mode 100644 vue/toolkit/src/composables/forms/useMaskedField/index.test.ts create mode 100644 vue/toolkit/src/composables/forms/useMaskedField/index.ts create mode 100644 vue/toolkit/src/composables/forms/useMaskedField/types.ts create mode 100644 vue/toolkit/src/composables/forms/useMaskedInput/demo.vue create mode 100644 vue/toolkit/src/composables/forms/useMaskedInput/index.test.ts create mode 100644 vue/toolkit/src/composables/forms/useMaskedInput/index.ts create mode 100644 vue/toolkit/src/composables/forms/useMaskedInput/types.ts diff --git a/core/encoding/src/index.ts b/core/encoding/src/index.ts index 0de9894..450cbcf 100644 --- a/core/encoding/src/index.ts +++ b/core/encoding/src/index.ts @@ -1,2 +1,3 @@ +export * from './luhn'; export * from './reed-solomon'; export * from './qr'; diff --git a/core/encoding/src/luhn/index.test.ts b/core/encoding/src/luhn/index.test.ts new file mode 100644 index 0000000..00eac10 --- /dev/null +++ b/core/encoding/src/luhn/index.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { luhn } from './index'; + +describe(luhn, () => { + it('passes valid checksums (separators ignored)', () => { + expect(luhn('4111 1111 1111 1111')).toBe(true); + expect(luhn('5500005555555559')).toBe(true); + expect(luhn('371449635398431')).toBe(true); + expect(luhn('79927398713')).toBe(true); // classic Luhn example + }); + + it('fails bad checksums', () => { + expect(luhn('4111 1111 1111 1112')).toBe(false); + expect(luhn('79927398710')).toBe(false); + }); + + it('returns false for empty input', () => { + expect(luhn('')).toBe(false); + expect(luhn('----')).toBe(false); + }); +}); diff --git a/core/encoding/src/luhn/index.ts b/core/encoding/src/luhn/index.ts new file mode 100644 index 0000000..05b4c75 --- /dev/null +++ b/core/encoding/src/luhn/index.ts @@ -0,0 +1,39 @@ +const NON_DIGIT = /\D/g; +const ASCII_ZERO = 0x30; + +/** + * @name luhn + * @category Encoding + * @description Validate a number string against the Luhn (mod 10) checksum — the + * check digit used by payment cards, IMEIs, SIM ICCIDs, and more. Non-digits are + * ignored; an empty input is `false`. + * + * @param {string} value The number (separators allowed) + * @returns {boolean} Whether the Luhn checksum passes + * + * @example + * luhn('4111 1111 1111 1111'); // true + * luhn('4111 1111 1111 1112'); // false + * + * @since 0.0.2 + */ +export function luhn(value: string): boolean { + const digits = value.replaceAll(NON_DIGIT, ''); + if (!digits) + return false; + + let sum = 0; + let double = false; + for (let i = digits.length - 1; i >= 0; i--) { + let digit = digits.charCodeAt(i) - ASCII_ZERO; + if (double) { + digit *= 2; + if (digit > 9) + digit -= 9; + } + sum += digit; + double = !double; + } + + return sum % 10 === 0; +} diff --git a/core/platform/package.json b/core/platform/package.json index f2d13dd..9092c74 100644 --- a/core/platform/package.json +++ b/core/platform/package.json @@ -56,6 +56,7 @@ "build": "tsdown" }, "devDependencies": { + "@robonen/encoding": "workspace:*", "@robonen/eslint": "workspace:*", "@robonen/stdlib": "workspace:*", "@robonen/tsconfig": "workspace:*", diff --git a/core/platform/src/browsers/domStyle/index.test.ts b/core/platform/src/browsers/domStyle/index.test.ts new file mode 100644 index 0000000..c50075b --- /dev/null +++ b/core/platform/src/browsers/domStyle/index.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { assignStyle, getTranslate, isInView, resetStyle, setStyle } from './index'; + +function makeEl(): HTMLElement { + const el = document.createElement('div'); + document.body.append(el); + return el; +} + +describe('setStyle / resetStyle', () => { + it('applies styles and caches the overwritten values', () => { + const el = makeEl(); + el.style.transform = 'translateY(0px)'; + + setStyle(el, { transform: 'translateY(20px)', transition: 'none' }); + expect(el.style.transform).toBe('translateY(20px)'); + expect(el.style.transition).toBe('none'); + + resetStyle(el); + expect(el.style.transform).toBe('translateY(0px)'); + }); + + it('restores a single property when given prop', () => { + const el = makeEl(); + el.style.opacity = '1'; + + setStyle(el, { opacity: '0', transform: 'scale(0.9)' }); + resetStyle(el, 'opacity'); + + expect(el.style.opacity).toBe('1'); + expect(el.style.transform).toBe('scale(0.9)'); + }); + + it('writes custom properties via setProperty', () => { + const el = makeEl(); + setStyle(el, { '--snap-point-height': '120px' }); + expect(el.style.getPropertyValue('--snap-point-height')).toBe('120px'); + }); + + it('does not cache when ignoreCache is true', () => { + const el = makeEl(); + el.style.opacity = '1'; + + setStyle(el, { opacity: '0.5' }, true); + resetStyle(el); + + expect(el.style.opacity).toBe('0.5'); + }); + + it('is a no-op for non-elements', () => { + expect(() => setStyle(null, { opacity: '0' })).not.toThrow(); + expect(() => resetStyle(null)).not.toThrow(); + }); +}); + +describe('getTranslate', () => { + it('returns null when there is no matrix transform', () => { + const el = makeEl(); + expect(getTranslate(el, 'y')).toBeNull(); + }); + + it('reads x and y from a 2D matrix', () => { + const el = makeEl(); + el.style.transform = 'matrix(1, 0, 0, 1, 12, 34)'; + expect(getTranslate(el, 'x')).toBe(12); + expect(getTranslate(el, 'y')).toBe(34); + }); + + it('reads x and y from a 3D matrix', () => { + const el = makeEl(); + el.style.transform = 'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 60, 0, 1)'; + expect(getTranslate(el, 'x')).toBe(50); + expect(getTranslate(el, 'y')).toBe(60); + }); +}); + +describe('assignStyle', () => { + it('assigns styles and restores the previous cssText on cleanup', () => { + const el = makeEl(); + el.style.cssText = 'color: red;'; + + const restore = assignStyle(el, { overflow: 'hidden' }); + expect(el.style.overflow).toBe('hidden'); + + restore(); + expect(el.style.overflow).toBe(''); + expect(el.style.color).toBe('red'); + }); + + it('returns a no-op cleanup for a missing element', () => { + expect(() => assignStyle(null, { overflow: 'hidden' })()).not.toThrow(); + }); +}); + +describe('isInView', () => { + it('returns false when visualViewport is unavailable', () => { + const el = makeEl(); + const original = window.visualViewport; + Object.defineProperty(globalThis, 'visualViewport', { value: null, configurable: true }); + + expect(isInView(el)).toBe(false); + + Object.defineProperty(globalThis, 'visualViewport', { value: original, configurable: true }); + }); +}); diff --git a/core/platform/src/browsers/domStyle/index.ts b/core/platform/src/browsers/domStyle/index.ts new file mode 100644 index 0000000..b9adba5 --- /dev/null +++ b/core/platform/src/browsers/domStyle/index.ts @@ -0,0 +1,190 @@ +/** + * A patch of inline styles — a map of CSS property names to string values. + * Keys may be camelCase DOM style properties (`borderRadius`) or `--custom` + * properties (set verbatim via `setProperty`). + */ +export type StylePatch = Record; + +/** + * The axis a translation is read along: `x` (horizontal) or `y` (vertical). + */ +export type TranslateAxis = 'x' | 'y'; + +// Remembers the styles that {@link setStyle} overwrote, keyed by element, so +// {@link resetStyle} can put them back. A WeakMap lets the entry be collected +// once the element is gone. +const originalStyles = new WeakMap(); + +/** + * @name setStyle + * @category Browsers + * @description Applies a batch of inline styles to an element, remembering the + * values it overwrote so {@link resetStyle} can restore them later. `--custom` + * properties are written through `setProperty`. Pass `ignoreCache` to apply the + * styles without recording the originals (e.g. for transient, per-frame writes + * during a drag that you intend to clear wholesale). + * + * @param {Element | HTMLElement | null} [element] The element to style (ignored if not an `HTMLElement`) + * @param {StylePatch} [styles] The property/value pairs to apply + * @param {boolean} [ignoreCache] Skip remembering the overwritten values + * @returns {void} + * + * @example + * setStyle(el, { transition: 'none', transform: 'translateY(20px)' }); + * setStyle(el, { opacity: '0.5' }, true); // transient — won't be restored + * + * @since 0.0.5 + */ +export function setStyle(element?: Element | HTMLElement | null, styles?: StylePatch, ignoreCache = false): void { + if (!element || !(element instanceof HTMLElement) || !styles) + return; + + const previous: StylePatch = {}; + + for (const [key, value] of Object.entries(styles)) { + if (key.startsWith('--')) { + element.style.setProperty(key, value); + continue; + } + + previous[key] = (element.style as unknown as StylePatch)[key]; + (element.style as unknown as StylePatch)[key] = value; + } + + if (ignoreCache) + return; + + originalStyles.set(element, previous); +} + +/** + * @name resetStyle + * @category Browsers + * @description Restores the inline styles an element had before the most recent + * cached {@link setStyle}. With `prop` it restores a single property; otherwise + * it restores every property that was remembered. A no-op if nothing was cached. + * + * @param {Element | HTMLElement | null} element The element to restore + * @param {string} [prop] Restore only this property instead of all of them + * @returns {void} + * + * @example + * resetStyle(el); // restore everything setStyle changed + * resetStyle(el, 'transform'); // restore just the transform + * + * @since 0.0.5 + */ +export function resetStyle(element: Element | HTMLElement | null, prop?: string): void { + if (!element || !(element instanceof HTMLElement)) + return; + + const previous = originalStyles.get(element); + + if (!previous) + return; + + if (prop) { + (element.style as unknown as StylePatch)[prop] = previous[prop]; + return; + } + + for (const [key, value] of Object.entries(previous)) + (element.style as unknown as StylePatch)[key] = value; +} + +/** + * @name getTranslate + * @category Browsers + * @description Reads the current translation of an element along one axis from + * its computed `transform`, parsing both `matrix(...)` (2D) and `matrix3d(...)` + * (3D) forms. Returns `null` when the element has no matrix transform. + * + * @param {HTMLElement} element The element to measure + * @param {TranslateAxis} axis `'x'` for horizontal, `'y'` for vertical + * @returns {number | null} The translation in pixels, or `null` if none + * + * @example + * const offset = getTranslate(panel, 'y'); // px the panel is shifted down + * + * @since 0.0.5 + */ +export function getTranslate(element: HTMLElement, axis: TranslateAxis): number | null { + const style = globalThis.getComputedStyle(element); + const transform + // @ts-expect-error — vendor-prefixed transforms only exist in some browsers + = style.transform || style.webkitTransform || style.mozTransform; + + let match = transform.match(/^matrix3d\((.+)\)$/); + if (match) { + // matrix3d: the translate components live at indices 12 (x) and 13 (y). + // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d + return Number.parseFloat(match[1].split(', ')[axis === 'y' ? 13 : 12]); + } + + // matrix: the translate components live at indices 4 (x) and 5 (y). + // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix + match = transform.match(/^matrix\((.+)\)$/); + return match ? Number.parseFloat(match[1].split(', ')[axis === 'y' ? 5 : 4]) : null; +} + +/** + * @name assignStyle + * @category Browsers + * @description Merges a style patch onto an element's inline `style` and returns + * a cleanup function that restores the element's entire previous `cssText`. + * Unlike {@link setStyle}, the snapshot is the full `cssText`, so the cleanup is + * an all-or-nothing revert — handy for scoped effects. + * + * @param {HTMLElement | null | undefined} element The element to style + * @param {Partial} style The styles to assign + * @returns {() => void} A cleanup function that restores the previous `cssText` + * + * @example + * const restore = assignStyle(document.body, { overflow: 'hidden' }); + * // ...later + * restore(); + * + * @since 0.0.5 + */ +export function assignStyle(element: HTMLElement | null | undefined, style: Partial): () => void { + if (!element) + return () => {}; + + const previousCssText = element.style.cssText; + Object.assign(element.style, style); + + return () => { + element.style.cssText = previousCssText; + }; +} + +/** + * @name isInView + * @category Browsers + * @description Reports whether an element is fully within the visual viewport, + * accounting for on-screen keyboards via `window.visualViewport`. A 40px slack + * is allowed at the bottom to tolerate Safari's viewport quirks. Returns `false` + * when `visualViewport` is unavailable. + * + * @param {HTMLElement} element The element to test + * @returns {boolean} `true` if the element's rect fits inside the visual viewport + * + * @example + * if (!isInView(focusedField)) scrollIntoView(focusedField); + * + * @since 0.0.5 + */ +export function isInView(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + + if (!window.visualViewport) + return false; + + return ( + rect.top >= 0 + && rect.left >= 0 + // +40 of slack for Safari's visual-viewport reporting. + && rect.bottom <= window.visualViewport.height - 40 + && rect.right <= window.visualViewport.width + ); +} diff --git a/core/platform/src/browsers/index.ts b/core/platform/src/browsers/index.ts index d82f88c..4f0ca1f 100644 --- a/core/platform/src/browsers/index.ts +++ b/core/platform/src/browsers/index.ts @@ -1,4 +1,7 @@ export * from './animationLifecycle'; +export * from './domStyle'; export * from './focusGuard'; export * from './focusScope'; export * from './hideOthers'; +export * from './inputState'; +export * from './userAgent'; diff --git a/core/platform/src/browsers/inputState/index.test.ts b/core/platform/src/browsers/inputState/index.test.ts new file mode 100644 index 0000000..839a2f6 --- /dev/null +++ b/core/platform/src/browsers/inputState/index.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { readInputState, writeInputState } from './index'; + +function makeInput(value = ''): HTMLInputElement { + const input = document.createElement('input'); + input.value = value; + document.body.append(input); + return input; +} + +describe('readInputState', () => { + it('reads value and selection', () => { + const input = makeInput('hello'); + input.setSelectionRange(1, 3); + + expect(readInputState(input)).toEqual({ value: 'hello', selection: [1, 3] }); + }); + + it('falls back to a collapsed caret at the end when selection is null', () => { + const input = makeInput('abc'); + // Force a null selection (some input types report null). + Object.defineProperty(input, 'selectionStart', { value: null }); + Object.defineProperty(input, 'selectionEnd', { value: null }); + + expect(readInputState(input)).toEqual({ value: 'abc', selection: [3, 3] }); + }); +}); + +describe('writeInputState', () => { + it('writes the value', () => { + const input = makeInput('a'); + writeInputState(input, { value: '(12)', selection: [4, 4] }); + + expect(input.value).toBe('(12)'); + }); + + it('moves the caret only while the element is focused', () => { + const input = makeInput('12345'); + input.focus(); + writeInputState(input, { value: '12345', selection: [2, 4] }); + + expect(input.selectionStart).toBe(2); + expect(input.selectionEnd).toBe(4); + }); + + it('does not reposition the caret when not focused', () => { + const input = makeInput('12345'); + input.setSelectionRange(0, 0); + input.blur(); + writeInputState(input, { value: '12345', selection: [3, 3] }); + + // Unfocused: selection is left untouched by setSelectionRange. + expect(input.selectionStart).toBe(0); + }); +}); diff --git a/core/platform/src/browsers/inputState/index.ts b/core/platform/src/browsers/inputState/index.ts new file mode 100644 index 0000000..7fbfc9b --- /dev/null +++ b/core/platform/src/browsers/inputState/index.ts @@ -0,0 +1,80 @@ +/** + * The editable state of a text field: its current text and selection bounds. + * Framework-agnostic and structurally compatible with any `{ value, selection }` + * pair (e.g. an input-masking engine's element state). + */ +export interface InputState { + /** + * The element's current `value`. + */ + readonly value: string; + /** + * The selection as `[from, to]` (collapsed caret when `from === to`). + */ + readonly selection: readonly [from: number, to: number]; +} + +/** + * A text field whose value and selection can be read and written. + */ +export type TextFieldElement = HTMLInputElement | HTMLTextAreaElement; + +/** + * @name readInputState + * @category Browsers + * @description Reads the value and current selection of an ``/`