From 59e995d0b550fbabdb3ea55881c11a84c3ec4d35 Mon Sep 17 00:00:00 2001 From: robonen Date: Mon, 8 Jun 2026 15:51:16 +0700 Subject: [PATCH] feat(vue): expand @robonen/vue composable collection Composables, tests, category barrels, and README for @robonen/vue. --- vue/toolkit/README.md | 37 +- .../src/composables/animation/index.ts | 12 + .../animation/useAnimate/index.test.ts | 355 ++++++++++ .../composables/animation/useAnimate/index.ts | 417 ++++++++++++ .../animation/useCountdown/index.test.ts | 187 ++++++ .../animation/useCountdown/index.ts | 147 +++++ .../animation/useDateFormat/index.test.ts | 157 +++++ .../animation/useDateFormat/index.ts | 217 ++++++ .../useInterval/index.test.ts | 0 .../useInterval/index.ts | 6 +- .../useIntervalFn/index.test.ts | 0 .../useIntervalFn/index.ts | 0 .../animation/useNow/index.test.ts | 140 ++++ .../src/composables/animation/useNow/index.ts | 110 ++++ .../useRafFn/index.test.ts | 0 .../{browser => animation}/useRafFn/index.ts | 0 .../animation/useTimeAgo/index.test.ts | 295 +++++++++ .../composables/animation/useTimeAgo/index.ts | 345 ++++++++++ .../animation/useTimeout/index.test.ts | 131 ++++ .../composables/animation/useTimeout/index.ts | 90 +++ .../useTimeoutFn/index.test.ts | 0 .../useTimeoutFn/index.ts | 2 +- .../useTimestamp/index.test.ts | 0 .../useTimestamp/index.ts | 6 +- .../animation/useTransition/index.test.ts | 315 +++++++++ .../animation/useTransition/index.ts | 360 ++++++++++ vue/toolkit/src/composables/array/index.ts | 13 + .../array/useArrayDifference/index.test.ts | 121 ++++ .../array/useArrayDifference/index.ts | 133 ++++ .../array/useArrayEvery/index.test.ts | 67 ++ .../composables/array/useArrayEvery/index.ts | 30 + .../useArrayFilter/index.test.ts | 0 .../useArrayFilter/index.ts | 2 +- .../useArrayFind/index.test.ts | 0 .../useArrayFind/index.ts | 2 +- .../array/useArrayFindIndex/index.test.ts | 69 ++ .../array/useArrayFindIndex/index.ts | 29 + .../array/useArrayFindLast/index.test.ts | 91 +++ .../array/useArrayFindLast/index.ts | 48 ++ .../array/useArrayIncludes/index.test.ts | 168 +++++ .../array/useArrayIncludes/index.ts | 115 ++++ .../array/useArrayJoin/index.test.ts | 103 +++ .../composables/array/useArrayJoin/index.ts | 42 ++ .../useArrayMap/index.test.ts | 0 .../useArrayMap/index.ts | 2 +- .../array/useArrayReduce/index.test.ts | 103 +++ .../composables/array/useArrayReduce/index.ts | 73 ++ .../array/useArraySome/index.test.ts | 83 +++ .../composables/array/useArraySome/index.ts | 30 + .../array/useArrayUnique/index.test.ts | 147 +++++ .../composables/array/useArrayUnique/index.ts | 127 ++++ .../composables/array/useSorted/index.test.ts | 150 +++++ .../src/composables/array/useSorted/index.ts | 151 +++++ .../broadcastedRef/index.test.ts | 0 .../broadcastedRef/index.ts | 2 +- vue/toolkit/src/composables/browser/index.ts | 60 +- .../browser/useBreakpoints/index.test.ts | 343 ++++++++++ .../browser/useBreakpoints/index.ts | 266 ++++++++ .../composables/browser/useClipboard/index.ts | 4 +- .../browser/useClipboardItems/index.test.ts | 228 +++++++ .../browser/useClipboardItems/index.ts | 185 ++++++ .../browser/useCloseWatcher/index.ts | 2 +- .../composables/browser/useColorMode/index.ts | 4 +- .../browser/useCssVar/index.test.ts | 234 +++++++ .../composables/browser/useCssVar/index.ts | 112 ++++ .../composables/browser/useDark/index.test.ts | 330 ++++++++++ .../src/composables/browser/useDark/index.ts | 97 +++ .../browser/useDocumentPiP/index.test.ts | 179 +++++ .../browser/useDocumentPiP/index.ts | 169 +++++ .../browser/useEyeDropper/index.ts | 2 +- .../browser/useFileSystemAccess/index.test.ts | 314 +++++++++ .../browser/useFileSystemAccess/index.ts | 332 ++++++++++ .../browser/useFullscreen/index.ts | 2 +- .../browser/useImage/index.test.ts | 254 +++++++ .../src/composables/browser/useImage/index.ts | 144 ++++ .../browser/useLocalFonts/index.test.ts | 151 +++++ .../browser/useLocalFonts/index.ts | 158 +++++ .../browser/useMediaQuery/index.ts | 2 +- .../browser/usePermission/index.ts | 2 +- .../usePreferredContrast/index.test.ts | 93 +++ .../browser/usePreferredContrast/index.ts | 61 ++ .../usePreferredLanguages/index.test.ts | 131 ++++ .../browser/usePreferredLanguages/index.ts | 43 ++ .../usePreferredReducedMotion/index.test.ts | 101 +++ .../usePreferredReducedMotion/index.ts | 41 ++ .../index.test.ts | 58 ++ .../usePreferredReducedTransparency/index.ts | 30 + .../browser/useScriptTag/index.test.ts | 261 ++++++++ .../composables/browser/useScriptTag/index.ts | 254 +++++++ .../src/composables/browser/useShare/index.ts | 2 +- .../browser/useStyleTag/index.test.ts | 176 +++++ .../composables/browser/useStyleTag/index.ts | 185 ++++++ .../composables/browser/useTabLeader/index.ts | 2 +- .../browser/useTextareaAutosize/index.test.ts | 231 +++++++ .../browser/useTextareaAutosize/index.ts | 178 +++++ .../src/composables/browser/useTitle/index.ts | 2 +- .../browser/useUrlSearchParams/index.test.ts | 293 +++++++++ .../browser/useUrlSearchParams/index.ts | 212 ++++++ .../composables/browser/useVibrate/index.ts | 8 +- .../browser/useWakeLock/index.test.ts | 224 +++++++ .../composables/browser/useWakeLock/index.ts | 143 ++++ .../browser/useWebNotification/index.test.ts | 282 ++++++++ .../browser/useWebNotification/index.ts | 293 +++++++++ .../createReusableTemplate/index.test.ts | 211 ++++++ .../component/createReusableTemplate/index.ts | 165 +++++ .../src/composables/component/index.ts | 3 + .../component/useCurrentElement/index.test.ts | 127 ++++ .../component/useCurrentElement/index.ts | 78 +++ .../component/useVirtualList/index.test.ts | 221 +++++++ .../component/useVirtualList/index.ts | 325 +++++++++ .../composables/debug/useRenderCount/index.ts | 2 +- .../composables/debug/useRenderInfo/index.ts | 2 +- vue/toolkit/src/composables/elements/index.ts | 17 + .../elements/onElementRemoval/index.test.ts | 157 +++++ .../elements/onElementRemoval/index.ts | 99 +++ .../useActiveElement/index.test.ts | 0 .../useActiveElement/index.ts | 4 +- .../useDocumentReadyState/index.test.ts | 0 .../useDocumentReadyState/index.ts | 2 +- .../useDocumentVisibility/index.test.ts | 0 .../useDocumentVisibility/index.ts | 2 +- .../elements/useDraggable/index.test.ts | 351 ++++++++++ .../elements/useDraggable/index.ts | 323 +++++++++ .../useDropZone/index.test.ts | 0 .../useDropZone/index.ts | 4 +- .../useElementBounding/index.test.ts | 0 .../useElementBounding/index.ts | 6 +- .../useElementSize/index.test.ts | 0 .../useElementSize/index.ts | 6 +- .../useElementVisibility/index.test.ts | 0 .../useElementVisibility/index.ts | 6 +- .../useFocusGuard/index.test.ts | 0 .../useFocusGuard/index.ts | 2 +- .../useIntersectionObserver/index.test.ts | 0 .../useIntersectionObserver/index.ts | 4 +- .../useMutationObserver/index.test.ts | 0 .../useMutationObserver/index.ts | 4 +- .../elements/useParentElement/index.test.ts | 121 ++++ .../elements/useParentElement/index.ts | 49 ++ .../useResizeObserver/index.test.ts | 0 .../useResizeObserver/index.ts | 4 +- .../useWindowFocus/index.test.ts | 0 .../useWindowFocus/index.ts | 2 +- .../useWindowScroll/index.test.ts | 0 .../useWindowScroll/index.ts | 6 +- .../useWindowSize/index.test.ts | 0 .../useWindowSize/index.ts | 2 +- vue/toolkit/src/composables/forms/index.ts | 4 + .../composables/forms/useField/index.test.ts | 134 ++++ .../src/composables/forms/useField/index.ts | 236 +++++++ .../forms/useFieldArray/index.test.ts | 109 +++ .../composables/forms/useFieldArray/index.ts | 146 ++++ .../src/composables/forms/useForm/context.ts | 25 + .../composables/forms/useForm/index.test.ts | 245 +++++++ .../src/composables/forms/useForm/index.ts | 506 ++++++++++++++ .../src/composables/forms/useForm/types.ts | 608 +++++++++++++++++ .../composables/forms/useForm/validation.ts | 73 ++ .../forms/useFormContext/index.test.ts | 44 ++ .../composables/forms/useFormContext/index.ts | 23 + vue/toolkit/src/composables/index.ts | 7 + vue/toolkit/src/composables/math/index.ts | 15 + .../composables/math/logicAnd/index.test.ts | 87 +++ .../src/composables/math/logicAnd/index.ts | 37 ++ .../composables/math/logicNot/index.test.ts | 76 +++ .../src/composables/math/logicNot/index.ts | 35 + .../composables/math/logicOr/index.test.ts | 98 +++ .../src/composables/math/logicOr/index.ts | 54 ++ .../src/composables/math/useAbs/index.test.ts | 64 ++ .../src/composables/math/useAbs/index.ts | 27 + .../composables/math/useAverage/index.test.ts | 116 ++++ .../src/composables/math/useAverage/index.ts | 55 ++ .../composables/math/useCeil/index.test.ts | 56 ++ .../src/composables/math/useCeil/index.ts | 25 + .../composables/math/useFloor/index.test.ts | 63 ++ .../src/composables/math/useFloor/index.ts | 23 + .../composables/math/useMath/index.test.ts | 113 ++++ .../src/composables/math/useMath/index.ts | 62 ++ .../src/composables/math/useMax/index.test.ts | 113 ++++ .../src/composables/math/useMax/index.ts | 55 ++ .../src/composables/math/useMin/index.test.ts | 89 +++ .../src/composables/math/useMin/index.ts | 52 ++ .../math/usePrecision/index.test.ts | 103 +++ .../composables/math/usePrecision/index.ts | 78 +++ .../math/useProjection/index.test.ts | 144 ++++ .../composables/math/useProjection/index.ts | 156 +++++ .../composables/math/useRound/index.test.ts | 106 +++ .../src/composables/math/useRound/index.ts | 67 ++ .../src/composables/math/useSum/index.test.ts | 116 ++++ .../src/composables/math/useSum/index.ts | 54 ++ .../composables/math/useTrunc/index.test.ts | 64 ++ .../src/composables/math/useTrunc/index.ts | 25 + vue/toolkit/src/composables/media/index.ts | 10 + .../media/useBluetooth/index.test.ts | 263 ++++++++ .../composables/media/useBluetooth/index.ts | 218 ++++++ .../media/useDisplayMedia/index.test.ts | 170 +++++ .../media/useDisplayMedia/index.ts | 172 +++++ .../media/useMediaControls/index.test.ts | 512 ++++++++++++++ .../media/useMediaControls/index.ts | 622 ++++++++++++++++++ .../composables/media/useMemory/index.test.ts | 143 ++++ .../src/composables/media/useMemory/index.ts | 115 ++++ .../usePerformanceObserver/index.test.ts | 178 +++++ .../media/usePerformanceObserver/index.ts | 135 ++++ .../media/useSpeechRecognition/index.test.ts | 360 ++++++++++ .../media/useSpeechRecognition/index.ts | 258 ++++++++ .../media/useSpeechRecognition/types.ts | 63 ++ .../media/useSpeechSynthesis/index.test.ts | 277 ++++++++ .../media/useSpeechSynthesis/index.ts | 240 +++++++ .../media/useUserMedia/index.test.ts | 343 ++++++++++ .../composables/media/useUserMedia/index.ts | 218 ++++++ .../media/useWebWorker/index.test.ts | 171 +++++ .../composables/media/useWebWorker/index.ts | 141 ++++ .../media/useWebWorkerFn/index.test.ts | 276 ++++++++ .../composables/media/useWebWorkerFn/index.ts | 235 +++++++ .../reactivity/computedAsync/index.test.ts | 212 ++++++ .../reactivity/computedAsync/index.ts | 183 ++++++ .../reactivity/computedEager/index.test.ts | 132 ++++ .../reactivity/computedEager/index.ts | 52 ++ .../computedWithControl/index.test.ts | 273 ++++++++ .../reactivity/computedWithControl/index.ts | 162 +++++ .../reactivity/extendRef/index.test.ts | 99 +++ .../composables/reactivity/extendRef/index.ts | 90 +++ .../src/composables/reactivity/index.ts | 26 +- .../reactivity/reactiveComputed/index.test.ts | 159 +++++ .../reactivity/reactiveComputed/index.ts | 82 +++ .../reactivity/reactiveOmit/index.test.ts | 144 ++++ .../reactivity/reactiveOmit/index.ts | 101 +++ .../reactivity/reactivePick/index.test.ts | 145 ++++ .../reactivity/reactivePick/index.ts | 113 ++++ .../reactivity/refAutoReset/index.ts | 2 +- .../reactivity/refDebounced/index.ts | 4 +- .../reactivity/refDefault/index.test.ts | 99 +++ .../reactivity/refDefault/index.ts | 50 ++ .../reactivity/refWithControl/index.test.ts | 249 +++++++ .../reactivity/refWithControl/index.ts | 159 +++++ .../reactivity/syncRef/index.test.ts | 184 ++++++ .../composables/reactivity/syncRef/index.ts | 167 +++++ .../reactivity/toReactive/index.test.ts | 141 ++++ .../reactivity/toReactive/index.ts | 73 ++ .../useDebounceFn/index.test.ts | 3 + .../useDebounceFn/index.ts | 2 +- .../useThrottleFn/index.test.ts | 0 .../useThrottleFn/index.ts | 2 +- vue/toolkit/src/composables/sensors/index.ts | 37 ++ .../onKeyStroke/index.test.ts | 0 .../{browser => sensors}/onKeyStroke/index.ts | 10 +- .../sensors/onLongPress/index.test.ts | 323 +++++++++ .../composables/sensors/onLongPress/index.ts | 227 +++++++ .../sensors/onStartTyping/index.test.ts | 315 +++++++++ .../sensors/onStartTyping/index.ts | 109 +++ .../sensors/useBattery/index.test.ts | 227 +++++++ .../composables/sensors/useBattery/index.ts | 139 ++++ .../useBodyScrollLock/index.test.ts | 0 .../useBodyScrollLock/index.ts | 2 +- .../useClickOutside/index.test.ts | 0 .../useClickOutside/index.ts | 2 +- .../sensors/useDeviceMotion/index.test.ts | 222 +++++++ .../sensors/useDeviceMotion/index.ts | 189 ++++++ .../useDeviceOrientation/index.test.ts | 132 ++++ .../sensors/useDeviceOrientation/index.ts | 81 +++ .../useDevicePixelRatio/index.test.ts | 0 .../useDevicePixelRatio/index.ts | 2 +- .../sensors/useDevicesList/index.test.ts | 285 ++++++++ .../sensors/useDevicesList/index.ts | 211 ++++++ .../sensors/useElementByPoint/index.test.ts | 267 ++++++++ .../sensors/useElementByPoint/index.ts | 120 ++++ .../useElementHover/index.test.ts | 0 .../useElementHover/index.ts | 2 +- .../useEscapeKey/index.test.ts | 0 .../useEscapeKey/index.ts | 2 +- .../useFocus/index.test.ts | 0 .../{browser => sensors}/useFocus/index.ts | 2 +- .../useFocusWithin/index.test.ts | 0 .../useFocusWithin/index.ts | 2 +- .../{browser => sensors}/useFps/index.test.ts | 0 .../{browser => sensors}/useFps/index.ts | 4 +- .../sensors/useGamepad/index.test.ts | 294 +++++++++ .../composables/sensors/useGamepad/index.ts | 324 +++++++++ .../useGeolocation/index.test.ts | 0 .../useGeolocation/index.ts | 4 +- .../useIdle/index.test.ts | 0 .../{browser => sensors}/useIdle/index.ts | 2 +- .../sensors/useInfiniteScroll/index.test.ts | 228 +++++++ .../sensors/useInfiniteScroll/index.ts | 241 +++++++ .../useKeyModifier/index.test.ts | 0 .../useKeyModifier/index.ts | 2 +- .../useMagicKeys/index.test.ts | 0 .../useMagicKeys/index.ts | 2 +- .../useMouse/index.test.ts | 0 .../{browser => sensors}/useMouse/index.ts | 2 +- .../sensors/useMouseInElement/index.test.ts | 191 ++++++ .../sensors/useMouseInElement/index.ts | 222 +++++++ .../useMousePressed/index.test.ts | 0 .../useMousePressed/index.ts | 4 +- .../useNetwork/index.test.ts | 0 .../{browser => sensors}/useNetwork/index.ts | 4 +- .../useOnline/index.test.ts | 0 .../{browser => sensors}/useOnline/index.ts | 2 +- .../usePageLeave/index.test.ts | 0 .../usePageLeave/index.ts | 2 +- .../sensors/useParallax/index.test.ts | 252 +++++++ .../composables/sensors/useParallax/index.ts | 183 ++++++ .../usePointer/index.test.ts | 0 .../{browser => sensors}/usePointer/index.ts | 2 +- .../sensors/usePointerLock/index.test.ts | 247 +++++++ .../sensors/usePointerLock/index.ts | 159 +++++ .../sensors/usePointerSwipe/index.test.ts | 255 +++++++ .../sensors/usePointerSwipe/index.ts | 246 +++++++ .../useScreenOrientation/index.test.ts | 0 .../useScreenOrientation/index.ts | 4 +- .../useScroll/index.test.ts | 0 .../{browser => sensors}/useScroll/index.ts | 8 +- .../useScrollLock/index.test.ts | 0 .../useScrollLock/index.ts | 2 +- .../useSwipe/index.test.ts | 0 .../{browser => sensors}/useSwipe/index.ts | 2 +- .../useTextSelection/index.test.ts | 0 .../useTextSelection/index.ts | 2 +- .../createSharedComposable/index.test.ts | 180 +++++ .../state/createSharedComposable/index.ts | 59 ++ vue/toolkit/src/composables/state/index.ts | 8 + .../useCycleList/index.test.ts | 0 .../useCycleList/index.ts | 2 +- .../useDebouncedRefHistory/index.test.ts | 255 +++++++ .../state/useDebouncedRefHistory/index.ts | 69 ++ .../useLastChanged/index.test.ts | 0 .../useLastChanged/index.ts | 2 +- .../state/useManualRefHistory/index.test.ts | 237 +++++++ .../state/useManualRefHistory/index.ts | 238 +++++++ .../useOffsetPagination/index.test.ts | 0 .../useOffsetPagination/index.ts | 2 +- .../state/useRefHistory/index.test.ts | 375 +++++++++++ .../composables/state/useRefHistory/index.ts | 339 ++++++++++ .../useThrottledRefHistory/index.test.ts | 243 +++++++ .../state/useThrottledRefHistory/index.ts | 67 ++ .../composables/storage/useStorage/index.ts | 2 +- .../utilities/createEventHook/index.test.ts | 209 ++++++ .../utilities/createEventHook/index.ts | 160 +++++ .../composables/utilities/get/index.test.ts | 72 ++ .../src/composables/utilities/get/index.ts | 43 ++ .../src/composables/utilities/index.ts | 13 +- .../utilities/isDefined/index.test.ts | 96 +++ .../composables/utilities/isDefined/index.ts | 73 ++ .../composables/utilities/set/index.test.ts | 108 +++ .../src/composables/utilities/set/index.ts | 40 ++ .../utilities/useEventBus/index.test.ts | 201 ++++++ .../utilities/useEventBus/index.ts | 167 +++++ .../utilities/useMemoize/index.test.ts | 216 ++++++ .../composables/utilities/useMemoize/index.ts | 174 +++++ .../useSupported/index.test.ts | 0 .../useSupported/index.ts | 2 +- vue/toolkit/src/composables/watch/index.ts | 7 + .../{reactivity => watch}/until/index.test.ts | 4 +- .../{reactivity => watch}/until/index.ts | 6 +- .../watchDebounced/index.test.ts | 0 .../watchDebounced/index.ts | 2 +- .../watchIgnorable/index.test.ts | 0 .../watchIgnorable/index.ts | 2 +- .../watchOnce/index.test.ts | 0 .../{reactivity => watch}/watchOnce/index.ts | 2 +- .../watchPausable/index.test.ts | 0 .../watchPausable/index.ts | 2 +- .../watchThrottled/index.test.ts | 0 .../watchThrottled/index.ts | 2 +- .../whenever/index.test.ts | 0 .../{reactivity => watch}/whenever/index.ts | 2 +- vue/toolkit/src/types/dom.ts | 95 +++ vue/toolkit/src/types/index.ts | 2 + vue/toolkit/src/types/ref.ts | 10 +- vue/toolkit/src/types/standard-schema.ts | 127 ++++ 369 files changed, 36554 insertions(+), 188 deletions(-) create mode 100644 vue/toolkit/src/composables/animation/index.ts create mode 100644 vue/toolkit/src/composables/animation/useAnimate/index.test.ts create mode 100644 vue/toolkit/src/composables/animation/useAnimate/index.ts create mode 100644 vue/toolkit/src/composables/animation/useCountdown/index.test.ts create mode 100644 vue/toolkit/src/composables/animation/useCountdown/index.ts create mode 100644 vue/toolkit/src/composables/animation/useDateFormat/index.test.ts create mode 100644 vue/toolkit/src/composables/animation/useDateFormat/index.ts rename vue/toolkit/src/composables/{utilities => animation}/useInterval/index.test.ts (100%) rename vue/toolkit/src/composables/{utilities => animation}/useInterval/index.ts (93%) rename vue/toolkit/src/composables/{browser => animation}/useIntervalFn/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => animation}/useIntervalFn/index.ts (100%) create mode 100644 vue/toolkit/src/composables/animation/useNow/index.test.ts create mode 100644 vue/toolkit/src/composables/animation/useNow/index.ts rename vue/toolkit/src/composables/{browser => animation}/useRafFn/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => animation}/useRafFn/index.ts (100%) create mode 100644 vue/toolkit/src/composables/animation/useTimeAgo/index.test.ts create mode 100644 vue/toolkit/src/composables/animation/useTimeAgo/index.ts create mode 100644 vue/toolkit/src/composables/animation/useTimeout/index.test.ts create mode 100644 vue/toolkit/src/composables/animation/useTimeout/index.ts rename vue/toolkit/src/composables/{utilities => animation}/useTimeoutFn/index.test.ts (100%) rename vue/toolkit/src/composables/{utilities => animation}/useTimeoutFn/index.ts (99%) rename vue/toolkit/src/composables/{utilities => animation}/useTimestamp/index.test.ts (100%) rename vue/toolkit/src/composables/{utilities => animation}/useTimestamp/index.ts (95%) create mode 100644 vue/toolkit/src/composables/animation/useTransition/index.test.ts create mode 100644 vue/toolkit/src/composables/animation/useTransition/index.ts create mode 100644 vue/toolkit/src/composables/array/index.ts create mode 100644 vue/toolkit/src/composables/array/useArrayDifference/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useArrayDifference/index.ts create mode 100644 vue/toolkit/src/composables/array/useArrayEvery/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useArrayEvery/index.ts rename vue/toolkit/src/composables/{reactivity => array}/useArrayFilter/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => array}/useArrayFilter/index.ts (97%) rename vue/toolkit/src/composables/{reactivity => array}/useArrayFind/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => array}/useArrayFind/index.ts (97%) create mode 100644 vue/toolkit/src/composables/array/useArrayFindIndex/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useArrayFindIndex/index.ts create mode 100644 vue/toolkit/src/composables/array/useArrayFindLast/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useArrayFindLast/index.ts create mode 100644 vue/toolkit/src/composables/array/useArrayIncludes/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useArrayIncludes/index.ts create mode 100644 vue/toolkit/src/composables/array/useArrayJoin/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useArrayJoin/index.ts rename vue/toolkit/src/composables/{reactivity => array}/useArrayMap/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => array}/useArrayMap/index.ts (96%) create mode 100644 vue/toolkit/src/composables/array/useArrayReduce/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useArrayReduce/index.ts create mode 100644 vue/toolkit/src/composables/array/useArraySome/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useArraySome/index.ts create mode 100644 vue/toolkit/src/composables/array/useArrayUnique/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useArrayUnique/index.ts create mode 100644 vue/toolkit/src/composables/array/useSorted/index.test.ts create mode 100644 vue/toolkit/src/composables/array/useSorted/index.ts rename vue/toolkit/src/composables/{reactivity => browser}/broadcastedRef/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => browser}/broadcastedRef/index.ts (98%) create mode 100644 vue/toolkit/src/composables/browser/useBreakpoints/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useBreakpoints/index.ts create mode 100644 vue/toolkit/src/composables/browser/useClipboardItems/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useClipboardItems/index.ts create mode 100644 vue/toolkit/src/composables/browser/useCssVar/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useCssVar/index.ts create mode 100644 vue/toolkit/src/composables/browser/useDark/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useDark/index.ts create mode 100644 vue/toolkit/src/composables/browser/useDocumentPiP/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useDocumentPiP/index.ts create mode 100644 vue/toolkit/src/composables/browser/useFileSystemAccess/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useFileSystemAccess/index.ts create mode 100644 vue/toolkit/src/composables/browser/useImage/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useImage/index.ts create mode 100644 vue/toolkit/src/composables/browser/useLocalFonts/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useLocalFonts/index.ts create mode 100644 vue/toolkit/src/composables/browser/usePreferredContrast/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/usePreferredContrast/index.ts create mode 100644 vue/toolkit/src/composables/browser/usePreferredLanguages/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/usePreferredLanguages/index.ts create mode 100644 vue/toolkit/src/composables/browser/usePreferredReducedMotion/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/usePreferredReducedMotion/index.ts create mode 100644 vue/toolkit/src/composables/browser/usePreferredReducedTransparency/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/usePreferredReducedTransparency/index.ts create mode 100644 vue/toolkit/src/composables/browser/useScriptTag/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useScriptTag/index.ts create mode 100644 vue/toolkit/src/composables/browser/useStyleTag/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useStyleTag/index.ts create mode 100644 vue/toolkit/src/composables/browser/useTextareaAutosize/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useTextareaAutosize/index.ts create mode 100644 vue/toolkit/src/composables/browser/useUrlSearchParams/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useUrlSearchParams/index.ts create mode 100644 vue/toolkit/src/composables/browser/useWakeLock/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useWakeLock/index.ts create mode 100644 vue/toolkit/src/composables/browser/useWebNotification/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useWebNotification/index.ts create mode 100644 vue/toolkit/src/composables/component/createReusableTemplate/index.test.ts create mode 100644 vue/toolkit/src/composables/component/createReusableTemplate/index.ts create mode 100644 vue/toolkit/src/composables/component/useCurrentElement/index.test.ts create mode 100644 vue/toolkit/src/composables/component/useCurrentElement/index.ts create mode 100644 vue/toolkit/src/composables/component/useVirtualList/index.test.ts create mode 100644 vue/toolkit/src/composables/component/useVirtualList/index.ts create mode 100644 vue/toolkit/src/composables/elements/index.ts create mode 100644 vue/toolkit/src/composables/elements/onElementRemoval/index.test.ts create mode 100644 vue/toolkit/src/composables/elements/onElementRemoval/index.ts rename vue/toolkit/src/composables/{browser => elements}/useActiveElement/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useActiveElement/index.ts (96%) rename vue/toolkit/src/composables/{browser => elements}/useDocumentReadyState/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useDocumentReadyState/index.ts (98%) rename vue/toolkit/src/composables/{browser => elements}/useDocumentVisibility/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useDocumentVisibility/index.ts (98%) create mode 100644 vue/toolkit/src/composables/elements/useDraggable/index.test.ts create mode 100644 vue/toolkit/src/composables/elements/useDraggable/index.ts rename vue/toolkit/src/composables/{browser => elements}/useDropZone/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useDropZone/index.ts (98%) rename vue/toolkit/src/composables/{browser => elements}/useElementBounding/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useElementBounding/index.ts (96%) rename vue/toolkit/src/composables/{browser => elements}/useElementSize/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useElementSize/index.ts (95%) rename vue/toolkit/src/composables/{browser => elements}/useElementVisibility/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useElementVisibility/index.ts (94%) rename vue/toolkit/src/composables/{browser => elements}/useFocusGuard/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useFocusGuard/index.ts (97%) rename vue/toolkit/src/composables/{browser => elements}/useIntersectionObserver/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useIntersectionObserver/index.ts (97%) rename vue/toolkit/src/composables/{browser => elements}/useMutationObserver/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useMutationObserver/index.ts (97%) create mode 100644 vue/toolkit/src/composables/elements/useParentElement/index.test.ts create mode 100644 vue/toolkit/src/composables/elements/useParentElement/index.ts rename vue/toolkit/src/composables/{browser => elements}/useResizeObserver/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useResizeObserver/index.ts (97%) rename vue/toolkit/src/composables/{browser => elements}/useWindowFocus/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useWindowFocus/index.ts (98%) rename vue/toolkit/src/composables/{browser => elements}/useWindowScroll/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useWindowScroll/index.ts (97%) rename vue/toolkit/src/composables/{browser => elements}/useWindowSize/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => elements}/useWindowSize/index.ts (99%) create mode 100644 vue/toolkit/src/composables/forms/index.ts create mode 100644 vue/toolkit/src/composables/forms/useField/index.test.ts create mode 100644 vue/toolkit/src/composables/forms/useField/index.ts create mode 100644 vue/toolkit/src/composables/forms/useFieldArray/index.test.ts create mode 100644 vue/toolkit/src/composables/forms/useFieldArray/index.ts create mode 100644 vue/toolkit/src/composables/forms/useForm/context.ts create mode 100644 vue/toolkit/src/composables/forms/useForm/index.test.ts create mode 100644 vue/toolkit/src/composables/forms/useForm/index.ts create mode 100644 vue/toolkit/src/composables/forms/useForm/types.ts create mode 100644 vue/toolkit/src/composables/forms/useForm/validation.ts create mode 100644 vue/toolkit/src/composables/forms/useFormContext/index.test.ts create mode 100644 vue/toolkit/src/composables/forms/useFormContext/index.ts create mode 100644 vue/toolkit/src/composables/math/logicAnd/index.test.ts create mode 100644 vue/toolkit/src/composables/math/logicAnd/index.ts create mode 100644 vue/toolkit/src/composables/math/logicNot/index.test.ts create mode 100644 vue/toolkit/src/composables/math/logicNot/index.ts create mode 100644 vue/toolkit/src/composables/math/logicOr/index.test.ts create mode 100644 vue/toolkit/src/composables/math/logicOr/index.ts create mode 100644 vue/toolkit/src/composables/math/useAbs/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useAbs/index.ts create mode 100644 vue/toolkit/src/composables/math/useAverage/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useAverage/index.ts create mode 100644 vue/toolkit/src/composables/math/useCeil/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useCeil/index.ts create mode 100644 vue/toolkit/src/composables/math/useFloor/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useFloor/index.ts create mode 100644 vue/toolkit/src/composables/math/useMath/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useMath/index.ts create mode 100644 vue/toolkit/src/composables/math/useMax/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useMax/index.ts create mode 100644 vue/toolkit/src/composables/math/useMin/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useMin/index.ts create mode 100644 vue/toolkit/src/composables/math/usePrecision/index.test.ts create mode 100644 vue/toolkit/src/composables/math/usePrecision/index.ts create mode 100644 vue/toolkit/src/composables/math/useProjection/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useProjection/index.ts create mode 100644 vue/toolkit/src/composables/math/useRound/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useRound/index.ts create mode 100644 vue/toolkit/src/composables/math/useSum/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useSum/index.ts create mode 100644 vue/toolkit/src/composables/math/useTrunc/index.test.ts create mode 100644 vue/toolkit/src/composables/math/useTrunc/index.ts create mode 100644 vue/toolkit/src/composables/media/index.ts create mode 100644 vue/toolkit/src/composables/media/useBluetooth/index.test.ts create mode 100644 vue/toolkit/src/composables/media/useBluetooth/index.ts create mode 100644 vue/toolkit/src/composables/media/useDisplayMedia/index.test.ts create mode 100644 vue/toolkit/src/composables/media/useDisplayMedia/index.ts create mode 100644 vue/toolkit/src/composables/media/useMediaControls/index.test.ts create mode 100644 vue/toolkit/src/composables/media/useMediaControls/index.ts create mode 100644 vue/toolkit/src/composables/media/useMemory/index.test.ts create mode 100644 vue/toolkit/src/composables/media/useMemory/index.ts create mode 100644 vue/toolkit/src/composables/media/usePerformanceObserver/index.test.ts create mode 100644 vue/toolkit/src/composables/media/usePerformanceObserver/index.ts create mode 100644 vue/toolkit/src/composables/media/useSpeechRecognition/index.test.ts create mode 100644 vue/toolkit/src/composables/media/useSpeechRecognition/index.ts create mode 100644 vue/toolkit/src/composables/media/useSpeechRecognition/types.ts create mode 100644 vue/toolkit/src/composables/media/useSpeechSynthesis/index.test.ts create mode 100644 vue/toolkit/src/composables/media/useSpeechSynthesis/index.ts create mode 100644 vue/toolkit/src/composables/media/useUserMedia/index.test.ts create mode 100644 vue/toolkit/src/composables/media/useUserMedia/index.ts create mode 100644 vue/toolkit/src/composables/media/useWebWorker/index.test.ts create mode 100644 vue/toolkit/src/composables/media/useWebWorker/index.ts create mode 100644 vue/toolkit/src/composables/media/useWebWorkerFn/index.test.ts create mode 100644 vue/toolkit/src/composables/media/useWebWorkerFn/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/computedAsync/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/computedAsync/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/computedEager/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/computedEager/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/computedWithControl/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/computedWithControl/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/extendRef/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/extendRef/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/reactiveComputed/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/reactiveComputed/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/reactiveOmit/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/reactiveOmit/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/reactivePick/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/reactivePick/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/refDefault/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/refDefault/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/refWithControl/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/refWithControl/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/syncRef/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/syncRef/index.ts create mode 100644 vue/toolkit/src/composables/reactivity/toReactive/index.test.ts create mode 100644 vue/toolkit/src/composables/reactivity/toReactive/index.ts rename vue/toolkit/src/composables/{utilities => reactivity}/useDebounceFn/index.test.ts (96%) rename vue/toolkit/src/composables/{utilities => reactivity}/useDebounceFn/index.ts (99%) rename vue/toolkit/src/composables/{utilities => reactivity}/useThrottleFn/index.test.ts (100%) rename vue/toolkit/src/composables/{utilities => reactivity}/useThrottleFn/index.ts (99%) create mode 100644 vue/toolkit/src/composables/sensors/index.ts rename vue/toolkit/src/composables/{browser => sensors}/onKeyStroke/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/onKeyStroke/index.ts (98%) create mode 100644 vue/toolkit/src/composables/sensors/onLongPress/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/onLongPress/index.ts create mode 100644 vue/toolkit/src/composables/sensors/onStartTyping/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/onStartTyping/index.ts create mode 100644 vue/toolkit/src/composables/sensors/useBattery/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/useBattery/index.ts rename vue/toolkit/src/composables/{browser => sensors}/useBodyScrollLock/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useBodyScrollLock/index.ts (99%) rename vue/toolkit/src/composables/{browser => sensors}/useClickOutside/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useClickOutside/index.ts (99%) create mode 100644 vue/toolkit/src/composables/sensors/useDeviceMotion/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/useDeviceMotion/index.ts create mode 100644 vue/toolkit/src/composables/sensors/useDeviceOrientation/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/useDeviceOrientation/index.ts rename vue/toolkit/src/composables/{browser => sensors}/useDevicePixelRatio/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useDevicePixelRatio/index.ts (98%) create mode 100644 vue/toolkit/src/composables/sensors/useDevicesList/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/useDevicesList/index.ts create mode 100644 vue/toolkit/src/composables/sensors/useElementByPoint/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/useElementByPoint/index.ts rename vue/toolkit/src/composables/{browser => sensors}/useElementHover/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useElementHover/index.ts (99%) rename vue/toolkit/src/composables/{browser => sensors}/useEscapeKey/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useEscapeKey/index.ts (98%) rename vue/toolkit/src/composables/{browser => sensors}/useFocus/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useFocus/index.ts (99%) rename vue/toolkit/src/composables/{browser => sensors}/useFocusWithin/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useFocusWithin/index.ts (99%) rename vue/toolkit/src/composables/{browser => sensors}/useFps/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useFps/index.ts (94%) create mode 100644 vue/toolkit/src/composables/sensors/useGamepad/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/useGamepad/index.ts rename vue/toolkit/src/composables/{browser => sensors}/useGeolocation/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useGeolocation/index.ts (98%) rename vue/toolkit/src/composables/{browser => sensors}/useIdle/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useIdle/index.ts (99%) create mode 100644 vue/toolkit/src/composables/sensors/useInfiniteScroll/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/useInfiniteScroll/index.ts rename vue/toolkit/src/composables/{browser => sensors}/useKeyModifier/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useKeyModifier/index.ts (99%) rename vue/toolkit/src/composables/{browser => sensors}/useMagicKeys/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useMagicKeys/index.ts (99%) rename vue/toolkit/src/composables/{browser => sensors}/useMouse/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useMouse/index.ts (99%) create mode 100644 vue/toolkit/src/composables/sensors/useMouseInElement/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/useMouseInElement/index.ts rename vue/toolkit/src/composables/{browser => sensors}/useMousePressed/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useMousePressed/index.ts (97%) rename vue/toolkit/src/composables/{browser => sensors}/useNetwork/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useNetwork/index.ts (98%) rename vue/toolkit/src/composables/{browser => sensors}/useOnline/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useOnline/index.ts (98%) rename vue/toolkit/src/composables/{browser => sensors}/usePageLeave/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/usePageLeave/index.ts (98%) create mode 100644 vue/toolkit/src/composables/sensors/useParallax/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/useParallax/index.ts rename vue/toolkit/src/composables/{browser => sensors}/usePointer/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/usePointer/index.ts (99%) create mode 100644 vue/toolkit/src/composables/sensors/usePointerLock/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/usePointerLock/index.ts create mode 100644 vue/toolkit/src/composables/sensors/usePointerSwipe/index.test.ts create mode 100644 vue/toolkit/src/composables/sensors/usePointerSwipe/index.ts rename vue/toolkit/src/composables/{browser => sensors}/useScreenOrientation/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useScreenOrientation/index.ts (97%) rename vue/toolkit/src/composables/{browser => sensors}/useScroll/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useScroll/index.ts (97%) rename vue/toolkit/src/composables/{browser => sensors}/useScrollLock/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useScrollLock/index.ts (99%) rename vue/toolkit/src/composables/{browser => sensors}/useSwipe/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useSwipe/index.ts (99%) rename vue/toolkit/src/composables/{browser => sensors}/useTextSelection/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => sensors}/useTextSelection/index.ts (99%) create mode 100644 vue/toolkit/src/composables/state/createSharedComposable/index.test.ts create mode 100644 vue/toolkit/src/composables/state/createSharedComposable/index.ts rename vue/toolkit/src/composables/{reactivity => state}/useCycleList/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => state}/useCycleList/index.ts (99%) create mode 100644 vue/toolkit/src/composables/state/useDebouncedRefHistory/index.test.ts create mode 100644 vue/toolkit/src/composables/state/useDebouncedRefHistory/index.ts rename vue/toolkit/src/composables/{reactivity => state}/useLastChanged/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => state}/useLastChanged/index.ts (98%) create mode 100644 vue/toolkit/src/composables/state/useManualRefHistory/index.test.ts create mode 100644 vue/toolkit/src/composables/state/useManualRefHistory/index.ts rename vue/toolkit/src/composables/{utilities => state}/useOffsetPagination/index.test.ts (100%) rename vue/toolkit/src/composables/{utilities => state}/useOffsetPagination/index.ts (99%) create mode 100644 vue/toolkit/src/composables/state/useRefHistory/index.test.ts create mode 100644 vue/toolkit/src/composables/state/useRefHistory/index.ts create mode 100644 vue/toolkit/src/composables/state/useThrottledRefHistory/index.test.ts create mode 100644 vue/toolkit/src/composables/state/useThrottledRefHistory/index.ts create mode 100644 vue/toolkit/src/composables/utilities/createEventHook/index.test.ts create mode 100644 vue/toolkit/src/composables/utilities/createEventHook/index.ts create mode 100644 vue/toolkit/src/composables/utilities/get/index.test.ts create mode 100644 vue/toolkit/src/composables/utilities/get/index.ts create mode 100644 vue/toolkit/src/composables/utilities/isDefined/index.test.ts create mode 100644 vue/toolkit/src/composables/utilities/isDefined/index.ts create mode 100644 vue/toolkit/src/composables/utilities/set/index.test.ts create mode 100644 vue/toolkit/src/composables/utilities/set/index.ts create mode 100644 vue/toolkit/src/composables/utilities/useEventBus/index.test.ts create mode 100644 vue/toolkit/src/composables/utilities/useEventBus/index.ts create mode 100644 vue/toolkit/src/composables/utilities/useMemoize/index.test.ts create mode 100644 vue/toolkit/src/composables/utilities/useMemoize/index.ts rename vue/toolkit/src/composables/{browser => utilities}/useSupported/index.test.ts (100%) rename vue/toolkit/src/composables/{browser => utilities}/useSupported/index.ts (97%) create mode 100644 vue/toolkit/src/composables/watch/index.ts rename vue/toolkit/src/composables/{reactivity => watch}/until/index.test.ts (95%) rename vue/toolkit/src/composables/{reactivity => watch}/until/index.ts (98%) rename vue/toolkit/src/composables/{reactivity => watch}/watchDebounced/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => watch}/watchDebounced/index.ts (99%) rename vue/toolkit/src/composables/{reactivity => watch}/watchIgnorable/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => watch}/watchIgnorable/index.ts (99%) rename vue/toolkit/src/composables/{reactivity => watch}/watchOnce/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => watch}/watchOnce/index.ts (98%) rename vue/toolkit/src/composables/{reactivity => watch}/watchPausable/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => watch}/watchPausable/index.ts (99%) rename vue/toolkit/src/composables/{reactivity => watch}/watchThrottled/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => watch}/watchThrottled/index.ts (99%) rename vue/toolkit/src/composables/{reactivity => watch}/whenever/index.test.ts (100%) rename vue/toolkit/src/composables/{reactivity => watch}/whenever/index.ts (98%) create mode 100644 vue/toolkit/src/types/dom.ts create mode 100644 vue/toolkit/src/types/standard-schema.ts diff --git a/vue/toolkit/README.md b/vue/toolkit/README.md index 8c04cfd..638ca49 100644 --- a/vue/toolkit/README.md +++ b/vue/toolkit/README.md @@ -1,6 +1,6 @@ # @robonen/vue -Collection of composables and utilities for Vue 3 — 100+ tree-shakeable, SSR-safe composables. +Collection of composables and utilities for Vue 3 — 213+ tree-shakeable, SSR-safe composables. ## Install @@ -10,32 +10,39 @@ pnpm install @robonen/vue ## Composables -| Category | Composables | +| Category | Composables | | -------------- | ----------- | -| **browser** | `onKeyStroke`, `useActiveElement`, `useBodyScrollLock`, `useClickOutside`, `useClipboard`, `useCloseWatcher`, `useColorMode`, `useDevicePixelRatio`, `useDocumentReadyState`, `useDocumentVisibility`, `useDropZone`, `useElementBounding`, `useElementHover`, `useElementSize`, `useElementVisibility`, `useEscapeKey`, `useEventListener`, `useEyeDropper`, `useFavicon`, `useFileDialog`, `useFocus`, `useFocusGuard`, `useFocusWithin`, `useFps`, `useFullscreen`, `useGeolocation`, `useIdle`, `useIntersectionObserver`, `useIntervalFn`, `useKeyModifier`, `useMagicKeys`, `useMediaQuery`, `useMouse`, `useMousePressed`, `useMutationObserver`, `useNetwork`, `useObjectUrl`, `useOnline`, `usePageLeave`, `usePermission`, `usePointer`, `usePreferredColorScheme`, `usePreferredDark`, `useRafFn`, `useResizeObserver`, `useScreenOrientation`, `useScroll`, `useScrollLock`, `useShare`, `useSupported`, `useSwipe`, `useTabLeader`, `useTextSelection`, `useTitle`, `useVibrate`, `useWindowFocus`, `useWindowScroll`, `useWindowSize` | -| **component** | `unrefElement`, `useForwardExpose`, `useTemplateRefsList` | -| **debug** | `useRenderCount`, `useRenderInfo` | -| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` | -| **math** | `useClamp` | -| **reactivity** | `broadcastedRef`, `refAutoReset`, `refDebounced`, `refThrottled`, `until`, `useArrayFilter`, `useArrayFind`, `useArrayMap`, `useCached`, `useCloned`, `useCycleList`, `useLastChanged`, `usePrevious`, `useSyncRefs`, `useToNumber`, `useToString`, `watchDebounced`, `watchIgnorable`, `watchOnce`, `watchPausable`, `watchThrottled`, `whenever` | -| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useId`, `useInjectionStore`, `useStepper`, `useToggle` | -| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` | -| **utilities** | `useDebounceFn`, `useInterval`, `useOffsetPagination`, `useThrottleFn`, `useTimeoutFn`, `useTimestamp` | +| **animation** | `useAnimate`, `useCountdown`, `useDateFormat`, `useInterval`, `useIntervalFn`, `useNow`, `useRafFn`, `useTimeAgo`, `useTimeout`, `useTimeoutFn`, `useTimestamp`, `useTransition` | +| **array** | `useArrayDifference`, `useArrayEvery`, `useArrayFilter`, `useArrayFind`, `useArrayFindIndex`, `useArrayFindLast`, `useArrayIncludes`, `useArrayJoin`, `useArrayMap`, `useArrayReduce`, `useArraySome`, `useArrayUnique`, `useSorted` | +| **browser** | `broadcastedRef`, `useBreakpoints`, `useClipboard`, `useClipboardItems`, `useCloseWatcher`, `useColorMode`, `useCssVar`, `useDark`, `useDocumentPiP`, `useEventListener`, `useEyeDropper`, `useFavicon`, `useFileDialog`, `useFileSystemAccess`, `useFullscreen`, `useImage`, `useLocalFonts`, `useMediaQuery`, `useObjectUrl`, `usePermission`, `usePreferredColorScheme`, `usePreferredContrast`, `usePreferredDark`, `usePreferredLanguages`, `usePreferredReducedMotion`, `usePreferredReducedTransparency`, `useScriptTag`, `useShare`, `useStyleTag`, `useTabLeader`, `useTextareaAutosize`, `useTitle`, `useUrlSearchParams`, `useVibrate`, `useWakeLock`, `useWebNotification` | +| **component** | `createReusableTemplate`, `unrefElement`, `useCurrentElement`, `useForwardExpose`, `useTemplateRefsList`, `useVirtualList` | +| **debug** | `useRenderCount`, `useRenderInfo` | +| **elements** | `onElementRemoval`, `useActiveElement`, `useDocumentReadyState`, `useDocumentVisibility`, `useDraggable`, `useDropZone`, `useElementBounding`, `useElementSize`, `useElementVisibility`, `useFocusGuard`, `useIntersectionObserver`, `useMutationObserver`, `useParentElement`, `useResizeObserver`, `useWindowFocus`, `useWindowScroll`, `useWindowSize` | +| **forms** | `useField`, `useFieldArray`, `useForm`, `useFormContext` | +| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` | +| **math** | `logicAnd`, `logicNot`, `logicOr`, `useAbs`, `useAverage`, `useCeil`, `useClamp`, `useFloor`, `useMath`, `useMax`, `useMin`, `usePrecision`, `useProjection`, `useRound`, `useSum`, `useTrunc` | +| **media** | `useBluetooth`, `useDisplayMedia`, `useMediaControls`, `useMemory`, `usePerformanceObserver`, `useSpeechRecognition`, `useSpeechSynthesis`, `useUserMedia`, `useWebWorker`, `useWebWorkerFn` | +| **reactivity** | `computedAsync`, `computedEager`, `computedWithControl`, `extendRef`, `reactiveComputed`, `reactiveOmit`, `reactivePick`, `refAutoReset`, `refDebounced`, `refDefault`, `refThrottled`, `refWithControl`, `syncRef`, `toReactive`, `useCached`, `useCloned`, `useDebounceFn`, `usePrevious`, `useSyncRefs`, `useThrottleFn`, `useToNumber`, `useToString` | +| **sensors** | `onKeyStroke`, `onLongPress`, `onStartTyping`, `useBattery`, `useBodyScrollLock`, `useClickOutside`, `useDeviceMotion`, `useDeviceOrientation`, `useDevicePixelRatio`, `useDevicesList`, `useElementByPoint`, `useElementHover`, `useEscapeKey`, `useFocus`, `useFocusWithin`, `useFps`, `useGamepad`, `useGeolocation`, `useIdle`, `useInfiniteScroll`, `useKeyModifier`, `useMagicKeys`, `useMouse`, `useMouseInElement`, `useMousePressed`, `useNetwork`, `useOnline`, `usePageLeave`, `useParallax`, `usePointer`, `usePointerLock`, `usePointerSwipe`, `useScreenOrientation`, `useScroll`, `useScrollLock`, `useSwipe`, `useTextSelection` | +| **state** | `createSharedComposable`, `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useCycleList`, `useDebouncedRefHistory`, `useId`, `useInjectionStore`, `useLastChanged`, `useManualRefHistory`, `useOffsetPagination`, `useRefHistory`, `useStepper`, `useThrottledRefHistory`, `useToggle` | +| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` | +| **utilities** | `createEventHook`, `get`, `isDefined`, `set`, `useEventBus`, `useMemoize`, `useSupported` | +| **watch** | `until`, `watchDebounced`, `watchIgnorable`, `watchOnce`, `watchPausable`, `watchThrottled`, `whenever` | -The package also exports event-filter helpers (`debounceFilter`, `throttleFilter`, `pausableFilter`, `createFilterWrapper`) and shared types (`ConfigurableWindow`, `ConfigurableDocument`, `ConfigurableNavigator`, `MaybeComputedElementRef`, …). +The package also exports event-filter helpers (`debounceFilter`, `throttleFilter`, `pausableFilter`, `createFilterWrapper`) and shared types (`ConfigurableWindow`, `ConfigurableDocument`, `ConfigurableNavigator`, `ConfigurableFlush`, `MaybeComputedElementRef`, …). ## Usage ```ts -import { useEventListener, useMagicKeys, useToggle } from '@robonen/vue'; +import { useEventListener, useMagicKeys, useToggle } from @robonen/vue; const { value, toggle } = useToggle(); -useEventListener('scroll', () => {/* … */}, { passive: true }); +useEventListener(scroll, () => {/* … */}, { passive: true }); const keys = useMagicKeys(); watchEffect(() => { - if (keys['ctrl+s'].value) + if (keys[ctrl+s].value) save(); }); ``` diff --git a/vue/toolkit/src/composables/animation/index.ts b/vue/toolkit/src/composables/animation/index.ts new file mode 100644 index 0000000..1110f45 --- /dev/null +++ b/vue/toolkit/src/composables/animation/index.ts @@ -0,0 +1,12 @@ +export * from './useAnimate'; +export * from './useCountdown'; +export * from './useDateFormat'; +export * from './useInterval'; +export * from './useIntervalFn'; +export * from './useNow'; +export * from './useRafFn'; +export * from './useTimeAgo'; +export * from './useTimeout'; +export * from './useTimeoutFn'; +export * from './useTimestamp'; +export * from './useTransition'; diff --git a/vue/toolkit/src/composables/animation/useAnimate/index.test.ts b/vue/toolkit/src/composables/animation/useAnimate/index.test.ts new file mode 100644 index 0000000..b250b9b --- /dev/null +++ b/vue/toolkit/src/composables/animation/useAnimate/index.test.ts @@ -0,0 +1,355 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useAnimate } from '.'; + +// --- Stub Animation ------------------------------------------------------- + +type Listener = (ev: any) => void; + +class StubAnimation { + effect: any; + timeline: any = { kind: 'document' }; + startTime: number | null = null; + currentTime: number | null = 0; + playbackRate = 1; + pending = false; + playState: AnimationPlayState = 'idle'; + replaceState: AnimationReplaceState = 'active'; + + play = vi.fn(() => { + this.playState = 'running'; + }); + + pause = vi.fn(() => { + this.playState = 'paused'; + }); + + reverse = vi.fn(() => { + this.playbackRate = -1; + this.playState = 'running'; + }); + + finish = vi.fn(() => { + this.playState = 'finished'; + }); + + cancel = vi.fn(() => { + this.playState = 'idle'; + }); + + persist = vi.fn(); + commitStyles = vi.fn(); + + private listeners = new Map>(); + + addEventListener = vi.fn((type: string, listener: Listener) => { + if (!this.listeners.has(type)) + this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(listener); + }); + + removeEventListener = vi.fn((type: string, listener: Listener) => { + this.listeners.get(type)?.delete(listener); + }); + + dispatch(type: string) { + this.listeners.get(type)?.forEach(l => l({ type })); + } + + constructor(public keyframes: any, public options: any) { + instances.push(this); + } +} + +let instances: StubAnimation[] = []; +let animateSpy: ReturnType; + +// Flush enough rAF + microtasks for the useRafFn store sync to run. +async function flushFrames(count = 3) { + for (let i = 0; i < count; i++) { + await new Promise(resolve => requestAnimationFrame(() => resolve())); + await nextTick(); + } +} + +describe(useAnimate, () => { + beforeEach(() => { + instances = []; + animateSpy = vi.fn(function (this: HTMLElement, keyframes: any, options: any) { + return new StubAnimation(keyframes, options) as unknown as Animation; + }); + // jsdom does not implement the Web Animations API + Object.defineProperty(HTMLElement.prototype, 'animate', { + configurable: true, + writable: true, + value: animateSpy, + }); + vi.stubGlobal('KeyframeEffect', class { + constructor(public el: any, public kf: any, public opts: any) {} + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete (HTMLElement.prototype as any).animate; + }); + + it('reports support when Element.animate exists', () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(ref(el), [{ opacity: 0 }, { opacity: 1 }], 1000); + }); + + expect(api.isSupported.value).toBeTruthy(); + scope.stop(); + }); + + it('creates the animation immediately for a resolved target', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(ref(el), [{ opacity: 0 }, { opacity: 1 }], 1000); + }); + + await flushFrames(); + + expect(animateSpy).toHaveBeenCalledTimes(1); + expect(animateSpy).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], 1000); + expect(api.animate.value).toBe(instances[0] as unknown as Animation); + scope.stop(); + }); + + it('passes an options object through to animate, stripping reserved keys', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => { + useAnimate(ref(el), { opacity: [0, 1] }, { + duration: 500, + easing: 'ease-in', + immediate: true, + commitStyles: true, + }); + }); + + await flushFrames(); + + expect(animateSpy).toHaveBeenCalledWith({ opacity: [0, 1] }, { duration: 500, easing: 'ease-in' }); + expect(instances[0]!.options).not.toHaveProperty('immediate'); + expect(instances[0]!.options).not.toHaveProperty('commitStyles'); + scope.stop(); + }); + + it('does not auto-play when immediate is false', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, immediate: false }); + }); + + await flushFrames(); + + expect(instances[0]!.pause).toHaveBeenCalled(); + expect(api.animate.value).toBeDefined(); + scope.stop(); + }); + + it('play / pause / reverse / finish / cancel delegate to the Animation', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(ref(el), [{ opacity: 0 }], 1000); + }); + + await flushFrames(); + const inst = instances[0]!; + + api.pause(); + expect(inst.pause).toHaveBeenCalled(); + + api.play(); + expect(inst.play).toHaveBeenCalled(); + + api.reverse(); + expect(inst.reverse).toHaveBeenCalled(); + + api.finish(); + expect(inst.finish).toHaveBeenCalled(); + + api.cancel(); + expect(inst.cancel).toHaveBeenCalled(); + scope.stop(); + }); + + it('syncs reactive playState from the live animation', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(ref(el), [{ opacity: 0 }], 1000); + }); + + await flushFrames(); + const inst = instances[0]!; + + inst.playState = 'running'; + inst.currentTime = 42; + api.play(); + await flushFrames(); + + expect(api.playState.value).toBe('running'); + expect(api.currentTime.value).toBe(42); + scope.stop(); + }); + + it('writes currentTime back to the animation', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(ref(el), [{ opacity: 0 }], 1000); + }); + + await flushFrames(); + const inst = instances[0]!; + + api.currentTime.value = 250; + expect(inst.currentTime).toBe(250); + expect(api.currentTime.value).toBe(250); + scope.stop(); + }); + + it('writes playbackRate back to the animation', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(ref(el), [{ opacity: 0 }], 1000); + }); + + await flushFrames(); + const inst = instances[0]!; + + api.playbackRate.value = 2; + expect(inst.playbackRate).toBe(2); + expect(api.playbackRate.value).toBe(2); + scope.stop(); + }); + + it('applies persist and initial playbackRate options', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => { + useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, persist: true, playbackRate: 3 }); + }); + + await flushFrames(); + const inst = instances[0]!; + + expect(inst.persist).toHaveBeenCalled(); + expect(inst.playbackRate).toBe(3); + scope.stop(); + }); + + it('commits styles on finish when commitStyles is set', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => { + useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, commitStyles: true }); + }); + + await flushFrames(); + const inst = instances[0]!; + + inst.dispatch('finish'); + expect(inst.commitStyles).toHaveBeenCalled(); + scope.stop(); + }); + + it('calls onReady with the created animation', async () => { + const el = document.createElement('div'); + const onReady = vi.fn(); + const scope = effectScope(); + scope.run(() => { + useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, onReady }); + }); + + await flushFrames(); + + expect(onReady).toHaveBeenCalledWith(instances[0]); + scope.stop(); + }); + + it('routes thrown errors to onError instead of throwing', async () => { + const el = document.createElement('div'); + const onError = vi.fn(); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, onError }); + }); + + await flushFrames(); + const inst = instances[0]!; + const boom = new Error('boom'); + inst.play.mockImplementationOnce(() => { + throw boom; + }); + + expect(() => api.play()).not.toThrow(); + expect(onError).toHaveBeenCalledWith(boom); + scope.stop(); + }); + + it('clears the animation when the target becomes null', async () => { + const el = document.createElement('div'); + const target = ref(el); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(target, [{ opacity: 0 }], 1000); + }); + + await flushFrames(); + expect(api.animate.value).toBeDefined(); + + target.value = null; + await nextTick(); + expect(api.animate.value).toBeUndefined(); + scope.stop(); + }); + + it('cancels the animation on scope dispose', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + scope.run(() => { + useAnimate(ref(el), [{ opacity: 0 }], 1000); + }); + + await flushFrames(); + const inst = instances[0]!; + + scope.stop(); + expect(inst.cancel).toHaveBeenCalled(); + }); + + it('is not supported and never animates when window is undefined (SSR)', async () => { + const el = document.createElement('div'); + const scope = effectScope(); + let api!: ReturnType; + scope.run(() => { + api = useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, window: undefined }); + }); + + await flushFrames(); + + expect(api.isSupported.value).toBeFalsy(); + expect(animateSpy).not.toHaveBeenCalled(); + expect(api.animate.value).toBeUndefined(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/animation/useAnimate/index.ts b/vue/toolkit/src/composables/animation/useAnimate/index.ts new file mode 100644 index 0000000..c466b38 --- /dev/null +++ b/vue/toolkit/src/composables/animation/useAnimate/index.ts @@ -0,0 +1,417 @@ +import { computed, shallowReactive, shallowRef, toValue, watch } from 'vue'; +import type { ComputedRef, MaybeRef, Ref, ShallowRef, WritableComputedRef } from 'vue'; +import { isObject, noop, omit } from '@robonen/stdlib'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { unrefElement } from '@/composables/component/unrefElement'; +import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; +import { useSupported } from '@/composables/utilities/useSupported'; +import { useEventListener } from '@/composables/browser/useEventListener'; +import { useRafFn } from '@/composables/animation/useRafFn'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; + +export interface UseAnimateOptions extends KeyframeAnimationOptions, ConfigurableWindow { + /** + * Automatically call `play()` once the target element is resolved. + * + * @default true + */ + immediate?: boolean; + + /** + * Commit the end styling state of the animation to the element when it finishes. + * Usually paired with the `fill` option. + * + * @default false + */ + commitStyles?: boolean; + + /** + * Persist the animation so it is not automatically removed by the browser. + * + * @default false + */ + persist?: boolean; + + /** + * Called once the underlying `Animation` instance has been created. + */ + onReady?: (animation: Animation) => void; + + /** + * Called when an error is thrown while controlling the animation. + * + * @default noop + */ + onError?: (error: unknown) => void; +} + +export type UseAnimateKeyframes + = MaybeRef; + +export interface UseAnimateReturn { + /** + * Whether the Web Animations API is supported in the current environment + */ + isSupported: Readonly>; + + /** + * The underlying `Animation` instance, or `undefined` before it is created + */ + animate: ShallowRef; + + /** + * Start or resume the animation + */ + play: () => void; + + /** + * Suspend playback of the animation + */ + pause: () => void; + + /** + * Reverse the playback direction of the animation + */ + reverse: () => void; + + /** + * Seek the animation to the end of its active duration + */ + finish: () => void; + + /** + * Abort the animation, clearing its effects + */ + cancel: () => void; + + /** + * Whether the animation is currently waiting for an asynchronous operation + */ + pending: ComputedRef; + + /** + * The current playback state of the animation + */ + playState: ComputedRef; + + /** + * The current replace state of the animation + */ + replaceState: ComputedRef; + + /** + * The scheduled time at which the animation should begin (writable) + */ + startTime: WritableComputedRef; + + /** + * The current time value of the animation in milliseconds (writable) + */ + currentTime: WritableComputedRef; + + /** + * The timeline associated with the animation (writable) + */ + timeline: WritableComputedRef; + + /** + * The playback rate of the animation (writable) + */ + playbackRate: WritableComputedRef; +} + +interface AnimateStore { + startTime: CSSNumberish | number | null; + currentTime: CSSNumberish | null; + timeline: AnimationTimeline | null; + playbackRate: number; + pending: boolean; + playState: AnimationPlayState; + replaceState: AnimationReplaceState; +} + +const RESERVED_KEYS = [ + 'window', + 'immediate', + 'commitStyles', + 'persist', + 'onReady', + 'onError', +] as const; + +/** + * @name useAnimate + * @category Animation + * @description Reactive [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) + * wrapper for a single element. Exposes imperative controls (`play`, `pause`, `reverse`, + * `finish`, `cancel`) alongside reactive state (`playState`, `currentTime`, `playbackRate`, ...). + * The reactive state is synced via `requestAnimationFrame` only while the animation is running, + * so an idle animation costs nothing. SSR-safe: nothing touches the DOM until the element resolves. + * + * @param {MaybeComputedElementRef} target Element to animate (reactive ref, getter, or element) + * @param {UseAnimateKeyframes} keyframes Keyframes to animate, reactive + * @param {number | UseAnimateOptions} [options] Duration in ms, or full options object + * @returns {UseAnimateReturn} Support flag, the `Animation` instance, controls, and reactive state + * + * @example + * const el = useTemplateRef('el'); + * const { playState, play, pause } = useAnimate( + * el, + * [{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }], + * { duration: 1000, iterations: Infinity }, + * ); + * + * @example + * // Shorthand: third argument is the duration in milliseconds + * useAnimate(el, { opacity: [0, 1] }, 500); + * + * @since 0.0.15 + */ +export function useAnimate( + target: MaybeComputedElementRef, + keyframes: UseAnimateKeyframes, + options?: number | UseAnimateOptions, +): UseAnimateReturn { + let config: UseAnimateOptions; + let animateOptions: number | KeyframeAnimationOptions | undefined; + + if (isObject(options)) { + config = options; + animateOptions = omit(options, RESERVED_KEYS as unknown as Array); + } + else { + config = { duration: options }; + animateOptions = options; + } + + const { + immediate = true, + commitStyles = false, + persist = false, + playbackRate: initialPlaybackRate = 1, + onReady, + onError = noop, + } = config; + + // Honor an explicit `window: undefined` (SSR / opt-out) rather than letting a + // default parameter silently restore `defaultWindow`. + const window = 'window' in config ? config.window : defaultWindow; + + const isSupported = useSupported(() => + Boolean(window) && typeof HTMLElement !== 'undefined' && 'animate' in HTMLElement.prototype); + + const animate = shallowRef(undefined); + + const store = shallowReactive({ + startTime: null, + currentTime: null, + timeline: null, + playbackRate: initialPlaybackRate, + pending: false, + playState: immediate ? 'idle' : 'paused', + replaceState: 'active', + }); + + const pending = computed(() => store.pending); + const playState = computed(() => store.playState); + const replaceState = computed(() => store.replaceState); + + const startTime = computed({ + get: () => store.startTime, + set(value) { + store.startTime = value; + if (animate.value) + animate.value.startTime = value; + }, + }); + + const currentTime = computed({ + get: () => store.currentTime, + set(value) { + store.currentTime = value; + if (animate.value) { + animate.value.currentTime = value; + syncResume(); + } + }, + }); + + const timeline = computed({ + get: () => store.timeline, + set(value) { + store.timeline = value; + if (animate.value) + animate.value.timeline = value; + }, + }); + + const playbackRate = computed({ + get: () => store.playbackRate, + set(value) { + store.playbackRate = value; + if (animate.value) + animate.value.playbackRate = value; + }, + }); + + function update(init?: boolean): void { + const el = unrefElement(target); + if (!isSupported.value || !el) + return; + + if (!animate.value) + animate.value = (el as HTMLElement).animate(toValue(keyframes), animateOptions); + + if (persist) + animate.value.persist(); + + if (initialPlaybackRate !== 1) + animate.value.playbackRate = initialPlaybackRate; + + if (init && !immediate) + animate.value.pause(); + else + syncResume(); + + onReady?.(animate.value); + } + + function play(): void { + if (!animate.value) { + update(); + return; + } + + try { + animate.value.play(); + syncResume(); + } + catch (error) { + syncPause(); + onError(error); + } + } + + function pause(): void { + try { + animate.value?.pause(); + syncPause(); + } + catch (error) { + onError(error); + } + } + + function reverse(): void { + if (!animate.value) + update(); + + try { + animate.value?.reverse(); + syncResume(); + } + catch (error) { + syncPause(); + onError(error); + } + } + + function finish(): void { + try { + animate.value?.finish(); + syncPause(); + } + catch (error) { + onError(error); + } + } + + function cancel(): void { + try { + animate.value?.cancel(); + syncPause(); + } + catch (error) { + onError(error); + } + } + + // Sync the reactive store from the live Animation on every frame. The loop is + // paused by default and only resumed while the animation is actually playing, + // so an idle (or finished) animation incurs zero per-frame cost. + const { resume: resumeRaf, pause: pauseRaf } = useRafFn(() => { + const a = animate.value; + if (!a) + return; + + store.pending = a.pending; + store.playState = a.playState; + store.replaceState = a.replaceState; + store.startTime = a.startTime; + store.currentTime = a.currentTime; + store.timeline = a.timeline; + store.playbackRate = a.playbackRate; + }, { immediate: false, window }); + + function syncResume(): void { + if (isSupported.value) + resumeRaf(); + } + + function syncPause(): void { + // Defer the stop by one frame so the final state (e.g. 'finished') is captured + // before the loop halts. + if (isSupported.value && window) + window.requestAnimationFrame(pauseRaf); + } + + watch(() => unrefElement(target), (el) => { + if (el) + update(true); + else + animate.value = undefined; + }); + + watch(() => keyframes, (value) => { + if (!animate.value) + return; + + update(); + + const el = unrefElement(target); + if (el && typeof KeyframeEffect !== 'undefined') + animate.value.effect = new KeyframeEffect(el as HTMLElement, toValue(value), animateOptions); + }, { deep: true }); + + const listenerOptions = { passive: true }; + useEventListener(animate, ['cancel', 'finish', 'remove'], syncPause, listenerOptions); + useEventListener(animate, 'finish', () => { + if (commitStyles) + animate.value?.commitStyles(); + }, listenerOptions); + + tryOnMounted(() => update(true), { sync: false }); + + tryOnScopeDispose(cancel); + + return { + isSupported, + animate, + + play, + pause, + reverse, + finish, + cancel, + + pending, + playState, + replaceState, + startTime, + currentTime, + timeline, + playbackRate, + }; +} diff --git a/vue/toolkit/src/composables/animation/useCountdown/index.test.ts b/vue/toolkit/src/composables/animation/useCountdown/index.test.ts new file mode 100644 index 0000000..1461f81 --- /dev/null +++ b/vue/toolkit/src/composables/animation/useCountdown/index.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, isReadonly, ref } from 'vue'; +import { useCountdown } from '.'; + +describe(useCountdown, () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('does not start until start/resume is called (immediate defaults to false)', () => { + const { remaining } = useCountdown(5); + + expect(remaining.value).toBe(5); + vi.advanceTimersByTime(3000); + expect(remaining.value).toBe(5); + }); + + it('decrements remaining on each tick', () => { + const { remaining, start } = useCountdown(5); + + start(); + expect(remaining.value).toBe(5); + vi.advanceTimersByTime(1000); + expect(remaining.value).toBe(4); + vi.advanceTimersByTime(2000); + expect(remaining.value).toBe(2); + }); + + it('starts immediately when immediate is true', () => { + const { remaining } = useCountdown(3, { immediate: true }); + + vi.advanceTimersByTime(1000); + expect(remaining.value).toBe(2); + }); + + it('exposes a read-only remaining ref', () => { + const { remaining } = useCountdown(5); + expect(isReadonly(remaining)).toBeTruthy(); + }); + + it('stops at zero and never goes negative', () => { + const { remaining, start } = useCountdown(2); + + start(); + vi.advanceTimersByTime(5000); + expect(remaining.value).toBe(0); + }); + + it('calls onComplete exactly once when reaching zero', () => { + const onComplete = vi.fn(); + const { start } = useCountdown(2, { onComplete }); + + start(); + vi.advanceTimersByTime(2000); + expect(onComplete).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2000); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('calls onTick with the current remaining value', () => { + const onTick = vi.fn(); + const { start } = useCountdown(3, { onTick }); + + start(); + vi.advanceTimersByTime(1000); + expect(onTick).toHaveBeenLastCalledWith(2); + vi.advanceTimersByTime(1000); + expect(onTick).toHaveBeenLastCalledWith(1); + }); + + it('pauses and resumes from where it left off', () => { + const { remaining, start, pause, resume, isActive } = useCountdown(10); + + start(); + vi.advanceTimersByTime(2000); + expect(remaining.value).toBe(8); + + pause(); + expect(isActive.value).toBeFalsy(); + vi.advanceTimersByTime(3000); + expect(remaining.value).toBe(8); + + resume(); + expect(isActive.value).toBeTruthy(); + vi.advanceTimersByTime(2000); + expect(remaining.value).toBe(6); + }); + + it('does not resume when remaining is already zero', () => { + const { remaining, start, resume, isActive } = useCountdown(1); + + start(); + vi.advanceTimersByTime(1000); + expect(remaining.value).toBe(0); + expect(isActive.value).toBeFalsy(); + + resume(); + expect(isActive.value).toBeFalsy(); + }); + + it('stop pauses and resets remaining to the initial value', () => { + const { remaining, start, stop, isActive } = useCountdown(5); + + start(); + vi.advanceTimersByTime(2000); + expect(remaining.value).toBe(3); + + stop(); + expect(isActive.value).toBeFalsy(); + expect(remaining.value).toBe(5); + }); + + it('reset restores the initial value without changing the running state', () => { + const { remaining, start, reset, isActive } = useCountdown(5); + + start(); + vi.advanceTimersByTime(2000); + expect(remaining.value).toBe(3); + + reset(); + expect(remaining.value).toBe(5); + expect(isActive.value).toBeTruthy(); + + vi.advanceTimersByTime(1000); + expect(remaining.value).toBe(4); + }); + + it('reset and start accept an explicit countdown override', () => { + const { remaining, start, reset } = useCountdown(5); + + reset(8); + expect(remaining.value).toBe(8); + + start(3); + expect(remaining.value).toBe(3); + vi.advanceTimersByTime(1000); + expect(remaining.value).toBe(2); + }); + + it('honours a custom tick interval', () => { + const { remaining, start } = useCountdown(5, { interval: 500 }); + + start(); + vi.advanceTimersByTime(500); + expect(remaining.value).toBe(4); + vi.advanceTimersByTime(1000); + expect(remaining.value).toBe(2); + }); + + it('resolves a reactive initial countdown', () => { + const initial = ref(4); + const { remaining, reset } = useCountdown(initial); + + expect(remaining.value).toBe(4); + + initial.value = 9; + reset(); + expect(remaining.value).toBe(9); + }); + + it('toggle flips the active state', () => { + const { start, toggle, isActive } = useCountdown(10); + + start(); + expect(isActive.value).toBeTruthy(); + toggle(); + expect(isActive.value).toBeFalsy(); + toggle(); + expect(isActive.value).toBeTruthy(); + }); + + it('cleans up the interval when the scope is disposed', () => { + const scope = effectScope(); + let api: ReturnType | undefined; + + scope.run(() => { + api = useCountdown(10, { immediate: true }); + }); + + vi.advanceTimersByTime(2000); + expect(api!.remaining.value).toBe(8); + + scope.stop(); + vi.advanceTimersByTime(5000); + expect(api!.remaining.value).toBe(8); + }); +}); diff --git a/vue/toolkit/src/composables/animation/useCountdown/index.ts b/vue/toolkit/src/composables/animation/useCountdown/index.ts new file mode 100644 index 0000000..0c5259d --- /dev/null +++ b/vue/toolkit/src/composables/animation/useCountdown/index.ts @@ -0,0 +1,147 @@ +import { shallowReadonly, shallowRef, toValue } from 'vue'; +import type { MaybeRefOrGetter, ShallowRef } from 'vue'; +import type { ResumableActions } from '@/types'; +import { useIntervalFn } from '@/composables/animation/useIntervalFn'; +import type { UseIntervalFnReturn } from '@/composables/animation/useIntervalFn'; + +export interface UseCountdownOptions { + /** + * Tick interval in milliseconds. Each tick decrements `remaining` by one. + * + * @default 1000 + */ + interval?: MaybeRefOrGetter; + + /** + * Start the countdown immediately when the composable is created + * + * @default false + */ + immediate?: boolean; + + /** + * Callback invoked on every tick with the current remaining value + */ + onTick?: (remaining: number) => void; + + /** + * Callback invoked once when the countdown reaches zero + */ + onComplete?: () => void; +} + +export interface UseCountdownReturn extends ResumableActions { + /** + * The remaining seconds, read-only (use `reset`/`start` to change it) + */ + remaining: Readonly>; + + /** + * Whether the countdown is currently running + */ + isActive: UseIntervalFnReturn['isActive']; + + /** + * Reset `remaining` (defaults to the initial value) without changing the + * running state + */ + reset: (countdown?: MaybeRefOrGetter) => void; + + /** + * Pause the countdown and reset `remaining` to the initial value + */ + stop: () => void; + + /** + * Reset `remaining` (defaults to the initial value) and start counting down + */ + start: (countdown?: MaybeRefOrGetter) => void; +} + +/** + * @name useCountdown + * @category Animation + * @description Reactive countdown timer exposing the remaining seconds plus + * `start`/`stop`/`pause`/`resume`/`reset` controls and `onTick`/`onComplete` + * callbacks. Built on `useIntervalFn`, so it is SSR-safe and cleans up on scope + * dispose. + * + * @param {MaybeRefOrGetter} initialCountdown The starting value, in seconds (can be reactive) + * @param {UseCountdownOptions} [options={}] Options + * @returns {UseCountdownReturn} The reactive remaining value and countdown controls + * + * @example + * const { remaining, start, pause, resume, stop } = useCountdown(60); + * start(); + * + * @example + * useCountdown(10, { + * immediate: true, + * onTick: (n) => console.log(n), + * onComplete: () => console.log('done'), + * }); + * + * @since 0.0.15 + */ +export function useCountdown( + initialCountdown: MaybeRefOrGetter, + options: UseCountdownOptions = {}, +): UseCountdownReturn { + const { + interval = 1000, + immediate = false, + onTick, + onComplete, + } = options; + + const remaining = shallowRef(toValue(initialCountdown)); + + const controls = useIntervalFn(() => { + const next = remaining.value - 1; + remaining.value = next < 0 ? 0 : next; + + onTick?.(remaining.value); + + if (remaining.value <= 0) { + controls.pause(); + onComplete?.(); + } + }, interval, { immediate }); + + const reset = (countdown?: MaybeRefOrGetter): void => { + remaining.value = toValue(countdown) ?? toValue(initialCountdown); + }; + + const stop = (): void => { + controls.pause(); + reset(); + }; + + const resume = (): void => { + if (!controls.isActive.value && remaining.value > 0) + controls.resume(); + }; + + const start = (countdown?: MaybeRefOrGetter): void => { + reset(countdown); + controls.resume(); + }; + + const toggle = (): void => { + if (controls.isActive.value) + controls.pause(); + else + resume(); + }; + + return { + remaining: shallowReadonly(remaining), + isActive: controls.isActive, + reset, + stop, + start, + pause: controls.pause, + resume, + toggle, + }; +} diff --git a/vue/toolkit/src/composables/animation/useDateFormat/index.test.ts b/vue/toolkit/src/composables/animation/useDateFormat/index.test.ts new file mode 100644 index 0000000..0e8c371 --- /dev/null +++ b/vue/toolkit/src/composables/animation/useDateFormat/index.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { effectScope, ref } from 'vue'; +import { formatDate, normalizeDate, useDateFormat } from '.'; + +// A fixed local date: 2024-03-09 18:07:05.042 (a Saturday). +function fixture(): Date { + return new Date(2024, 2, 9, 18, 7, 5, 42); +} + +describe(useDateFormat, () => { + it('defaults to HH:mm:ss', () => { + const formatted = useDateFormat(fixture()); + expect(formatted.value).toBe('18:07:05'); + }); + + it('formats year/month/day tokens', () => { + const date = fixture(); + expect(useDateFormat(date, 'YYYY-MM-DD').value).toBe('2024-03-09'); + expect(useDateFormat(date, 'YY/M/D').value).toBe('24/3/9'); + }); + + it('formats time tokens including milliseconds and 12-hour', () => { + const date = fixture(); + expect(useDateFormat(date, 'HH:mm:ss.SSS').value).toBe('18:07:05.042'); + expect(useDateFormat(date, 'h:mm').value).toBe('6:07'); + expect(useDateFormat(date, 'hh').value).toBe('06'); + }); + + it('handles 12 -> 12 and 0 -> 12 for the h token', () => { + expect(useDateFormat(new Date(2024, 0, 1, 0, 0, 0), 'h A').value).toBe('12 AM'); + expect(useDateFormat(new Date(2024, 0, 1, 12, 0, 0), 'h A').value).toBe('12 PM'); + }); + + it('formats the meridiem variants', () => { + const pm = fixture(); + expect(useDateFormat(pm, 'A').value).toBe('PM'); + expect(useDateFormat(pm, 'AA').value).toBe('P.M.'); + expect(useDateFormat(pm, 'a').value).toBe('pm'); + expect(useDateFormat(pm, 'aa').value).toBe('p.m.'); + + const am = new Date(2024, 0, 1, 6, 0, 0); + expect(useDateFormat(am, 'A').value).toBe('AM'); + }); + + it('formats ordinal tokens', () => { + const date = new Date(2024, 0, 1, 3, 0, 0); // Jan 1st, 3 o'clock + expect(useDateFormat(date, 'Do').value).toBe('1st'); + expect(useDateFormat(date, 'Mo').value).toBe('1st'); + expect(useDateFormat(new Date(2024, 1, 22), 'Do').value).toBe('22nd'); + expect(useDateFormat(new Date(2024, 1, 23), 'Do').value).toBe('23rd'); + expect(useDateFormat(new Date(2024, 1, 11), 'Do').value).toBe('11th'); + }); + + it('formats localized weekday and month with the locales option', () => { + const date = fixture(); // a Saturday in March + expect(useDateFormat(date, 'dddd', { locales: 'en-US' }).value).toBe('Saturday'); + expect(useDateFormat(date, 'ddd', { locales: 'en-US' }).value).toBe('Sat'); + expect(useDateFormat(date, 'MMMM', { locales: 'en-US' }).value).toBe('March'); + expect(useDateFormat(date, 'MMM', { locales: 'en-US' }).value).toBe('Mar'); + expect(useDateFormat(date, 'd', { locales: 'en-US' }).value).toBe('6'); // Saturday + }); + + it('uses a custom meridiem function', () => { + const date = fixture(); + const formatted = useDateFormat(date, 'h:mm a', { + customMeridiem: hours => (hours < 12 ? 'morning' : 'evening'), + }); + expect(formatted.value).toBe('6:07 evening'); + }); + + it('emits [literal] escapes verbatim', () => { + const date = fixture(); + expect(useDateFormat(date, '[Year:] YYYY').value).toBe('Year: 2024'); + expect(useDateFormat(date, '[YYYY] YYYY').value).toBe('YYYY 2024'); + }); + + it('is reactive to the date, format, and locale', () => { + const date = ref(fixture()); + const format = ref('YYYY'); + const locale = ref('en-US'); + const formatted = useDateFormat(date, format, { locales: locale }); + + expect(formatted.value).toBe('2024'); + + date.value = new Date(2025, 0, 1); + expect(formatted.value).toBe('2025'); + + format.value = 'MMMM'; + expect(formatted.value).toBe('January'); + + locale.value = 'fr-FR'; + expect(formatted.value).toBe('janvier'); + }); + + it('accepts a numeric timestamp and a getter', () => { + const date = fixture(); + expect(useDateFormat(date.getTime(), 'YYYY-MM-DD').value).toBe('2024-03-09'); + expect(useDateFormat(() => date, 'YYYY').value).toBe('2024'); + }); + + it('parses loose date strings without a trailing Z', () => { + expect(useDateFormat('2024-03-09', 'YYYY-MM-DD').value).toBe('2024-03-09'); + expect(useDateFormat('2024-3', 'YYYY-MM-DD').value).toBe('2024-03-01'); + expect(useDateFormat('2024-03-09 18:07:05', 'HH:mm:ss').value).toBe('18:07:05'); + }); + + it('handles null/undefined by resolving to now without throwing', () => { + expect(useDateFormat(undefined, 'YYYY').value).toMatch(/^\d{4}$/); + expect(useDateFormat(null, 'YYYY').value).toMatch(/^\d{4}$/); + }); + + it('returns "Invalid Date" for unparseable input instead of NaN tokens', () => { + expect(useDateFormat('not a date', 'YYYY-MM-DD').value).toBe('Invalid Date'); + expect(useDateFormat(Number.NaN, 'HH:mm:ss').value).toBe('Invalid Date'); + }); + + it('constructs inside an effect scope without throwing (SSR-safe, no global access)', () => { + const scope = effectScope(); + let formatted: ReturnType | undefined; + + scope.run(() => { + formatted = useDateFormat(fixture(), 'YYYY-MM-DD'); + }); + + expect(formatted?.value).toBe('2024-03-09'); + scope.stop(); + }); +}); + +describe(formatDate, () => { + it('formats a date one-shot', () => { + expect(formatDate(fixture(), 'YYYY/MM/DD')).toBe('2024/03/09'); + }); + + it('returns "Invalid Date" for an invalid date', () => { + expect(formatDate(new Date(Number.NaN), 'YYYY')).toBe('Invalid Date'); + }); +}); + +describe(normalizeDate, () => { + it('returns a fresh Date for a Date input', () => { + const date = fixture(); + const normalized = normalizeDate(date); + expect(normalized).not.toBe(date); + expect(normalized.getTime()).toBe(date.getTime()); + }); + + it('resolves null/undefined to a valid current Date', () => { + expect(Number.isNaN(normalizeDate(undefined).getTime())).toBeFalsy(); + expect(Number.isNaN(normalizeDate(null).getTime())).toBeFalsy(); + }); + + it('parses a numeric timestamp', () => { + const date = fixture(); + expect(normalizeDate(date.getTime()).getTime()).toBe(date.getTime()); + }); +}); diff --git a/vue/toolkit/src/composables/animation/useDateFormat/index.ts b/vue/toolkit/src/composables/animation/useDateFormat/index.ts new file mode 100644 index 0000000..bd8938b --- /dev/null +++ b/vue/toolkit/src/composables/animation/useDateFormat/index.ts @@ -0,0 +1,217 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import { isDate, isString } from '@robonen/stdlib'; + +/** + * Accepted input for {@link useDateFormat}: a `Date`, a millisecond timestamp, + * a parseable date string, or `null`/`undefined` (resolves to "now"). + */ +export type DateLike = Date | number | string | null | undefined; + +/** + * Signature for a custom meridiem (AM/PM) formatter. + * + * @param hours The hour of the day, 0-23 + * @param minutes The minute of the hour, 0-59 + * @param isLowercase Whether the token requested a lowercase form (`a`/`aa`) + * @param hasPeriod Whether the token requested period separators (`AA`/`aa`) + */ +export type CustomMeridiem + = (hours: number, minutes: number, isLowercase?: boolean, hasPeriod?: boolean) => string; + +export interface UseDateFormatOptions { + /** + * The locale(s) used for the `dd`/`ddd`/`dddd`/`MMM`/`MMMM`/`z` tokens. + * + * Accepts a reactive value (ref or getter); the output recomputes when it + * changes. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument + */ + locales?: MaybeRefOrGetter; + + /** + * A custom function controlling how the meridiem (`A`/`AA`/`a`/`aa`) is + * rendered. + */ + customMeridiem?: CustomMeridiem; +} + +/** + * Reactive formatted date string. + */ +export type UseDateFormatReturn = ComputedRef; + +// Matches a token, or a `[literal]` escape that is emitted verbatim. +const REGEX_FORMAT + = /* #__PURE__ */ /[YMDHhms]o|\[([^\]]+)\]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a{1,2}|A{1,2}|m{1,2}|s{1,2}|z{1,4}|SSS/g; + +// Loose ISO-ish parser used for date strings without a trailing `Z`. The optional +// separators make adjacent digit groups technically "misleading" to the linter, +// but this is the deliberate lenient dayjs parser (accepts `2024-01-01` and +// `20240101`); JS lacks possessive quantifiers to disambiguate it. +// eslint-disable-next-line regexp/no-misleading-capturing-group +const REGEX_PARSE = /* #__PURE__ */ /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[T\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/i; + +const ORDINAL_SUFFIXES = ['th', 'st', 'nd', 'rd'] as const; + +function defaultMeridiem( + hours: number, + _minutes: number, + isLowercase?: boolean, + hasPeriod?: boolean, +): string { + let m = hours < 12 ? 'AM' : 'PM'; + if (hasPeriod) m = `${m[0]}.${m[1]}.`; + return isLowercase ? m.toLowerCase() : m; +} + +function formatOrdinal(num: number): string { + const v = num % 100; + return num + (ORDINAL_SUFFIXES[(v - 20) % 10] || ORDINAL_SUFFIXES[v] || ORDINAL_SUFFIXES[0]); +} + +/** + * Coerce a {@link DateLike} into a `Date`. `null`/`undefined` become the + * current time; a non-UTC string is parsed leniently so partial dates such as + * `'2024-3'` are accepted. + * + * @param date The value to coerce + * @returns A `Date` instance (possibly `Invalid Date`) + */ +export function normalizeDate(date: DateLike): Date { + if (date === null || date === undefined) return new Date(); + if (isDate(date)) return new Date(date.getTime()); + if (isString(date) && !/z$/i.test(date)) { + const d = REGEX_PARSE.exec(date); + if (d) { + const month = d[2] ? Number(d[2]) - 1 : 0; + const ms = (d[7] || '0').slice(0, 3); + return new Date( + Number(d[1]), + month, + Number(d[3]) || 1, + Number(d[4]) || 0, + Number(d[5]) || 0, + Number(d[6]) || 0, + Number(ms), + ); + } + } + + return new Date(date); +} + +/** + * Format a `Date` against a token string. Exposed for one-shot, non-reactive + * formatting; {@link useDateFormat} wraps this in a `computed`. + * + * @param date The date to format + * @param formatStr The combination of tokens (e.g. `'YYYY-MM-DD HH:mm:ss'`) + * @param options Locale and meridiem options + * @returns The formatted string + */ +export function formatDate( + date: Date, + formatStr: string, + options: UseDateFormatOptions = {}, +): string { + // Invalid dates round-trip to the literal "Invalid Date" rather than + // emitting `NaN` for every numeric token. + if (Number.isNaN(date.getTime())) return 'Invalid Date'; + + const years = date.getFullYear(); + const month = date.getMonth(); + const days = date.getDate(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); + const milliseconds = date.getMilliseconds(); + const day = date.getDay(); + const hour12 = hours % 12 || 12; + + const locales = toValue(options.locales); + const meridiem = options.customMeridiem ?? defaultMeridiem; + // The timeZoneName lands after the date in the localized string; grab it. + const offsetName = (style: 'shortOffset' | 'longOffset'): string => + date.toLocaleDateString(locales, { timeZoneName: style }).split(' ')[1] ?? ''; + + const matches: Record string | number> = { + Yo: () => formatOrdinal(years), + YY: () => String(years).slice(-2), + YYYY: () => years, + M: () => month + 1, + Mo: () => formatOrdinal(month + 1), + MM: () => String(month + 1).padStart(2, '0'), + MMM: () => date.toLocaleDateString(locales, { month: 'short' }), + MMMM: () => date.toLocaleDateString(locales, { month: 'long' }), + D: () => String(days), + Do: () => formatOrdinal(days), + DD: () => String(days).padStart(2, '0'), + H: () => String(hours), + Ho: () => formatOrdinal(hours), + HH: () => String(hours).padStart(2, '0'), + h: () => String(hour12), + ho: () => formatOrdinal(hour12), + hh: () => String(hour12).padStart(2, '0'), + m: () => String(minutes), + mo: () => formatOrdinal(minutes), + mm: () => String(minutes).padStart(2, '0'), + s: () => String(seconds), + so: () => formatOrdinal(seconds), + ss: () => String(seconds).padStart(2, '0'), + SSS: () => String(milliseconds).padStart(3, '0'), + d: () => day, + dd: () => date.toLocaleDateString(locales, { weekday: 'narrow' }), + ddd: () => date.toLocaleDateString(locales, { weekday: 'short' }), + dddd: () => date.toLocaleDateString(locales, { weekday: 'long' }), + A: () => meridiem(hours, minutes), + AA: () => meridiem(hours, minutes, false, true), + a: () => meridiem(hours, minutes, true), + aa: () => meridiem(hours, minutes, true, true), + z: () => offsetName('shortOffset'), + zz: () => offsetName('shortOffset'), + zzz: () => offsetName('shortOffset'), + zzzz: () => offsetName('longOffset'), + }; + + return formatStr.replaceAll(REGEX_FORMAT, (match, literal) => + literal ?? String(matches[match]?.() ?? match), + ); +} + +/** + * @name useDateFormat + * @category Animation + * @description Reactively format a `Date`, timestamp, or date string against a + * token string (`YYYY MM DD HH mm ss SSS dddd A` etc.). Recomputes when the + * date, format, or locale changes. + * + * @param {MaybeRefOrGetter} date The date to format + * @param {MaybeRefOrGetter} [formatStr='HH:mm:ss'] The token string + * @param {UseDateFormatOptions} [options={}] Locale and meridiem options + * @returns {ComputedRef} The reactive formatted string + * + * @example + * const formatted = useDateFormat(useNow(), 'YYYY-MM-DD HH:mm:ss'); + * + * @example + * // Localized weekday + month, reactive locale + * const locale = ref('fr-FR'); + * const label = useDateFormat(date, 'dddd, MMMM D', { locales: locale }); + * + * @example + * // Custom meridiem + * const t = useDateFormat(date, 'hh:mm a', { + * customMeridiem: (h) => (h < 12 ? 'morning' : 'evening'), + * }); + * + * @since 0.0.15 + */ +export function useDateFormat( + date: MaybeRefOrGetter, + formatStr: MaybeRefOrGetter = 'HH:mm:ss', + options: UseDateFormatOptions = {}, +): UseDateFormatReturn { + return computed(() => formatDate(normalizeDate(toValue(date)), toValue(formatStr), options)); +} diff --git a/vue/toolkit/src/composables/utilities/useInterval/index.test.ts b/vue/toolkit/src/composables/animation/useInterval/index.test.ts similarity index 100% rename from vue/toolkit/src/composables/utilities/useInterval/index.test.ts rename to vue/toolkit/src/composables/animation/useInterval/index.test.ts diff --git a/vue/toolkit/src/composables/utilities/useInterval/index.ts b/vue/toolkit/src/composables/animation/useInterval/index.ts similarity index 93% rename from vue/toolkit/src/composables/utilities/useInterval/index.ts rename to vue/toolkit/src/composables/animation/useInterval/index.ts index ab72c96..6c9ccf6 100644 --- a/vue/toolkit/src/composables/utilities/useInterval/index.ts +++ b/vue/toolkit/src/composables/animation/useInterval/index.ts @@ -1,8 +1,8 @@ import { shallowReadonly, shallowRef } from 'vue'; import type { MaybeRefOrGetter, ShallowRef } from 'vue'; import type { ResumableActions } from '@/types'; -import { useIntervalFn } from '@/composables/browser/useIntervalFn'; -import type { UseIntervalFnReturn } from '@/composables/browser/useIntervalFn'; +import { useIntervalFn } from '@/composables/animation/useIntervalFn'; +import type { UseIntervalFnReturn } from '@/composables/animation/useIntervalFn'; export interface UseIntervalOptions { /** @@ -46,7 +46,7 @@ export type UseIntervalReturn = Readonly> | UseIntervalContro /** * @name useInterval - * @category Utilities + * @category Animation * @description Reactive counter that increments on every interval tick. * * @param {MaybeRefOrGetter} [interval=1000] Interval in milliseconds (can be reactive) diff --git a/vue/toolkit/src/composables/browser/useIntervalFn/index.test.ts b/vue/toolkit/src/composables/animation/useIntervalFn/index.test.ts similarity index 100% rename from vue/toolkit/src/composables/browser/useIntervalFn/index.test.ts rename to vue/toolkit/src/composables/animation/useIntervalFn/index.test.ts diff --git a/vue/toolkit/src/composables/browser/useIntervalFn/index.ts b/vue/toolkit/src/composables/animation/useIntervalFn/index.ts similarity index 100% rename from vue/toolkit/src/composables/browser/useIntervalFn/index.ts rename to vue/toolkit/src/composables/animation/useIntervalFn/index.ts diff --git a/vue/toolkit/src/composables/animation/useNow/index.test.ts b/vue/toolkit/src/composables/animation/useNow/index.test.ts new file mode 100644 index 0000000..7a081d8 --- /dev/null +++ b/vue/toolkit/src/composables/animation/useNow/index.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope } from 'vue'; +import type { Ref } from 'vue'; +import { useNow } from '.'; +import type { UseNowControls } from '.'; + +describe(useNow, () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + }); + afterEach(() => vi.useRealTimers()); + + it('returns the current date', () => { + const now = useNow({ interval: 100 }); + expect(now.value).toBeInstanceOf(Date); + expect(now.value.getTime()).toBe(1000); + }); + + it('updates on the interval', () => { + const now = useNow({ interval: 100 }); + + // advanceTimersByTime also advances the mocked clock, so the tick fires at 1100 + vi.advanceTimersByTime(100); + expect(now.value.getTime()).toBe(1100); + + vi.advanceTimersByTime(100); + expect(now.value.getTime()).toBe(1200); + }); + + it('exposes controls when controls: true', () => { + const { now, pause } = useNow({ controls: true, interval: 100 }); + + expect(now.value).toBeInstanceOf(Date); + + vi.advanceTimersByTime(100); + expect(now.value.getTime()).toBe(1100); + + pause(); + vi.advanceTimersByTime(100); + expect(now.value.getTime()).toBe(1100); + }); + + it('exposes isActive and reflects pause/resume/toggle', () => { + const { isActive, pause, resume, toggle } = useNow({ controls: true, interval: 100 }); + + expect(isActive.value).toBeTruthy(); + + pause(); + expect(isActive.value).toBeFalsy(); + + resume(); + expect(isActive.value).toBeTruthy(); + + toggle(); + expect(isActive.value).toBeFalsy(); + }); + + it('does not start updating when immediate is false', () => { + const { now, isActive } = useNow({ controls: true, interval: 100, immediate: false }); + + expect(isActive.value).toBeFalsy(); + + vi.advanceTimersByTime(100); + expect(now.value.getTime()).toBe(1000); + }); + + it('invokes the callback on every update with the current date', () => { + const callback = vi.fn(); + useNow({ interval: 100, callback }); + + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.lastCall?.[0]).toBeInstanceOf(Date); + expect((callback.mock.lastCall?.[0] as Date).getTime()).toBe(1100); + + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledTimes(2); + expect((callback.mock.lastCall?.[0] as Date).getTime()).toBe(1200); + }); + + it('produces a fresh Date instance on each update', () => { + const now = useNow({ interval: 100 }); + const first = now.value; + + vi.advanceTimersByTime(100); + expect(now.value).not.toBe(first); + expect(now.value.getTime()).toBe(1100); + }); + + it('defaults to the requestAnimationFrame strategy', () => { + const raf = vi.fn().mockReturnValue(1); + const caf = vi.fn(); + vi.stubGlobal('requestAnimationFrame', raf); + vi.stubGlobal('cancelAnimationFrame', caf); + + try { + const scope = effectScope(); + let result: UseNowControls | undefined; + + scope.run(() => { + result = useNow({ controls: true }); + }); + + // RAF strategy starts the loop immediately + expect(result?.isActive.value).toBeTruthy(); + expect(raf).toHaveBeenCalled(); + + scope.stop(); + } + finally { + vi.unstubAllGlobals(); + } + }); + + it('cleans up the updater when the scope is disposed', () => { + const scope = effectScope(); + let now: Ref | undefined; + + scope.run(() => { + now = useNow({ interval: 100 }); + }); + + vi.advanceTimersByTime(100); + expect(now?.value.getTime()).toBe(1100); + + scope.stop(); + + vi.advanceTimersByTime(100); + expect(now?.value.getTime()).toBe(1100); + }); + + it('does not update when interval mode runs without a callback firing (SSR-safe construction)', () => { + // useNow must construct without throwing even before any tick; the initial + // value is always a valid Date regardless of environment. + const now = useNow({ interval: 100, immediate: false }); + expect(now.value).toBeInstanceOf(Date); + expect(now.value.getTime()).toBe(1000); + }); +}); diff --git a/vue/toolkit/src/composables/animation/useNow/index.ts b/vue/toolkit/src/composables/animation/useNow/index.ts new file mode 100644 index 0000000..f5a8df5 --- /dev/null +++ b/vue/toolkit/src/composables/animation/useNow/index.ts @@ -0,0 +1,110 @@ +import { shallowRef } from 'vue'; +import type { Ref } from 'vue'; +import type { ResumableActions } from '@/types'; +import { useRafFn } from '@/composables/animation/useRafFn'; +import { useIntervalFn } from '@/composables/animation/useIntervalFn'; + +export interface UseNowOptions { + /** + * Expose pause/resume controls alongside the date + * + * @default false + */ + controls?: Controls; + + /** + * Start updating immediately + * + * @default true + */ + immediate?: boolean; + + /** + * Update strategy. `'requestAnimationFrame'` updates every frame; a number + * updates on a fixed interval (ms). + * + * @default 'requestAnimationFrame' + */ + interval?: 'requestAnimationFrame' | number; + + /** + * Callback invoked on every update with the current date + */ + callback?: (now: Date) => void; +} + +/** + * Pause/resume controls returned when `controls: true`. + */ +export interface UseNowControls extends ResumableActions { + /** + * The reactive current date + */ + now: Ref; + + /** + * Whether the updater (RAF loop or interval) is currently active + */ + isActive: Readonly>; +} + +export type UseNowReturn + = Controls extends true ? UseNowControls : Ref; + +/** + * @name useNow + * @category Animation + * @description Reactive current `Date`, updated via `requestAnimationFrame` + * or a fixed interval. + * + * @param {UseNowOptions} [options={}] Options + * @returns {Ref | UseNowControls} The date, or controls when `controls: true` + * + * @example + * const now = useNow(); + * + * @example + * const { now, pause, resume, isActive } = useNow({ controls: true, interval: 1000 }); + * + * @example + * // Run a callback on every update + * useNow({ interval: 1000, callback: date => console.log(date.toISOString()) }); + * + * @since 0.0.15 + */ +export function useNow(options?: UseNowOptions): Ref; +export function useNow(options: UseNowOptions): UseNowControls; +export function useNow( + options: UseNowOptions = {}, +): Ref | UseNowControls { + const { + controls = false, + immediate = true, + interval = 'requestAnimationFrame', + callback, + } = options; + + const now = shallowRef(new Date()); + + const update = callback + ? () => { + now.value = new Date(); + callback(now.value); + } + : () => { + now.value = new Date(); + }; + + const resumableControls = interval === 'requestAnimationFrame' + ? useRafFn(update, { immediate }) + : useIntervalFn(update, interval, { immediate }); + + if (controls) { + return { + now, + ...resumableControls, + }; + } + + return now; +} diff --git a/vue/toolkit/src/composables/browser/useRafFn/index.test.ts b/vue/toolkit/src/composables/animation/useRafFn/index.test.ts similarity index 100% rename from vue/toolkit/src/composables/browser/useRafFn/index.test.ts rename to vue/toolkit/src/composables/animation/useRafFn/index.test.ts diff --git a/vue/toolkit/src/composables/browser/useRafFn/index.ts b/vue/toolkit/src/composables/animation/useRafFn/index.ts similarity index 100% rename from vue/toolkit/src/composables/browser/useRafFn/index.ts rename to vue/toolkit/src/composables/animation/useRafFn/index.ts diff --git a/vue/toolkit/src/composables/animation/useTimeAgo/index.test.ts b/vue/toolkit/src/composables/animation/useTimeAgo/index.test.ts new file mode 100644 index 0000000..d87b11f --- /dev/null +++ b/vue/toolkit/src/composables/animation/useTimeAgo/index.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, shallowRef } from 'vue'; +import type { ComputedRef } from 'vue'; +import { formatTimeAgo, useTimeAgo } from '.'; +import type { UseTimeAgoControls, UseTimeAgoMessages } from '.'; + +const BASE = 1_700_000_000_000; // fixed epoch for deterministic diffs + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const WEEK = 7 * DAY; + +describe(formatTimeAgo, () => { + it('returns justNow when under a minute by default', () => { + expect(formatTimeAgo(new Date(BASE - 30 * SECOND), {}, BASE)).toBe('just now'); + }); + + it('shows seconds when showSecond is true', () => { + expect(formatTimeAgo(new Date(BASE - 30 * SECOND), { showSecond: true }, BASE)).toBe('30 seconds ago'); + }); + + it('formats minutes in the past', () => { + expect(formatTimeAgo(new Date(BASE - 3 * MINUTE), {}, BASE)).toBe('3 minutes ago'); + }); + + it('formats a single minute (no pluralization)', () => { + expect(formatTimeAgo(new Date(BASE - 1 * MINUTE), {}, BASE)).toBe('1 minute ago'); + }); + + it('formats future minutes', () => { + expect(formatTimeAgo(new Date(BASE + 5 * MINUTE), {}, BASE)).toBe('in 5 minutes'); + }); + + it('formats hours', () => { + expect(formatTimeAgo(new Date(BASE - 2 * HOUR), {}, BASE)).toBe('2 hours ago'); + }); + + it('uses the special yesterday/tomorrow forms for a single day', () => { + expect(formatTimeAgo(new Date(BASE - 1 * DAY), {}, BASE)).toBe('yesterday'); + expect(formatTimeAgo(new Date(BASE + 1 * DAY), {}, BASE)).toBe('tomorrow'); + }); + + it('uses last week / next week for a single week', () => { + expect(formatTimeAgo(new Date(BASE - 1 * WEEK), {}, BASE)).toBe('last week'); + expect(formatTimeAgo(new Date(BASE + 1 * WEEK), {}, BASE)).toBe('next week'); + }); + + it('falls back to the full date when a numeric max is exceeded', () => { + const from = new Date(BASE - 10 * DAY); + expect(formatTimeAgo(from, { max: 5 * DAY }, BASE)).toBe(from.toISOString().slice(0, 10)); + }); + + it('falls back to the full date when a named-unit max is exceeded', () => { + const from = new Date(BASE - 3 * WEEK); + expect(formatTimeAgo(from, { max: 'day' }, BASE)).toBe(from.toISOString().slice(0, 10)); + }); + + it('respects a custom fullDateFormatter', () => { + const from = new Date(BASE - 10 * DAY); + expect(formatTimeAgo(from, { max: 'day', fullDateFormatter: () => 'CUSTOM' }, BASE)).toBe('CUSTOM'); + }); + + it('honors a ceil rounding strategy', () => { + // 90 seconds -> 1.5 minutes -> ceil = 2 + expect(formatTimeAgo(new Date(BASE - 90 * SECOND), { rounding: 'ceil' }, BASE)).toBe('2 minutes ago'); + }); + + it('honors floor rounding', () => { + // 119 seconds -> 1.98 minutes -> floor = 1 + expect(formatTimeAgo(new Date(BASE - 119 * SECOND), { rounding: 'floor' }, BASE)).toBe('1 minute ago'); + }); + + it('honors numeric (decimal-place) rounding', () => { + // 90 seconds -> 1.5 minutes, rounded to 1 dp = 1.5 + expect(formatTimeAgo(new Date(BASE - 90 * SECOND), { rounding: 1 }, BASE)).toBe('1.5 minutes ago'); + }); + + it('returns the invalid message for an unparseable date', () => { + expect(formatTimeAgo(new Date('not a date'), {}, BASE)).toBe(''); + }); + + it('supports custom i18n messages', () => { + const messages: UseTimeAgoMessages = { + justNow: 'à l\'instant', + past: n => `il y a ${n}`, + future: n => `dans ${n}`, + invalid: 'invalide', + second: n => `${n} seconde${n > 1 ? 's' : ''}`, + minute: n => `${n} minute${n > 1 ? 's' : ''}`, + hour: n => `${n} heure${n > 1 ? 's' : ''}`, + day: n => `${n} jour${n > 1 ? 's' : ''}`, + week: n => `${n} semaine${n > 1 ? 's' : ''}`, + month: n => `${n} mois`, + year: n => `${n} an${n > 1 ? 's' : ''}`, + }; + + expect(formatTimeAgo(new Date(BASE - 3 * MINUTE), { messages }, BASE)).toBe('il y a 3 minutes'); + expect(formatTimeAgo(new Date(BASE + 3 * MINUTE), { messages }, BASE)).toBe('dans 3 minutes'); + }); + + it('supports string-template (i18n) past/future with {0} placeholder', () => { + const messages: UseTimeAgoMessages = { + justNow: 'now', + past: '{0} ago', + future: 'in {0}', + invalid: '', + second: '{0}s', + minute: '{0}m', + hour: '{0}h', + day: '{0}d', + week: '{0}w', + month: '{0}mo', + year: '{0}y', + }; + + expect(formatTimeAgo(new Date(BASE - 3 * MINUTE), { messages }, BASE)).toBe('3m ago'); + }); +}); + +describe(useTimeAgo, () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE); + }); + afterEach(() => vi.useRealTimers()); + + it('returns a computed string by default', () => { + const scope = effectScope(); + let timeAgo: ComputedRef | undefined; + + scope.run(() => { + timeAgo = useTimeAgo(new Date(BASE - 3 * MINUTE)); + }); + + expect(timeAgo?.value).toBe('3 minutes ago'); + scope.stop(); + }); + + it('recomputes as the clock advances on the interval', () => { + const scope = effectScope(); + let timeAgo: ComputedRef | undefined; + + scope.run(() => { + timeAgo = useTimeAgo(new Date(BASE - 5 * SECOND), { updateInterval: 1000, showSecond: true }); + }); + + expect(timeAgo?.value).toBe('5 seconds ago'); + + vi.advanceTimersByTime(55 * SECOND); + expect(timeAgo?.value).toBe('1 minute ago'); + + scope.stop(); + }); + + it('reacts to a changing reactive time source', () => { + const scope = effectScope(); + const time = shallowRef(new Date(BASE - 1 * MINUTE)); + let timeAgo: ComputedRef | undefined; + + scope.run(() => { + timeAgo = useTimeAgo(time); + }); + + expect(timeAgo?.value).toBe('1 minute ago'); + + time.value = new Date(BASE - 2 * HOUR); + expect(timeAgo?.value).toBe('2 hours ago'); + + scope.stop(); + }); + + it('accepts a numeric timestamp and a string date', () => { + const scope = effectScope(); + let fromNumber: ComputedRef | undefined; + let fromString: ComputedRef | undefined; + + scope.run(() => { + fromNumber = useTimeAgo(BASE - 3 * MINUTE); + fromString = useTimeAgo(new Date(BASE - 3 * MINUTE).toISOString()); + }); + + expect(fromNumber?.value).toBe('3 minutes ago'); + expect(fromString?.value).toBe('3 minutes ago'); + + scope.stop(); + }); + + it('exposes controls when controls: true', () => { + const scope = effectScope(); + let ctrl: UseTimeAgoControls | undefined; + + scope.run(() => { + ctrl = useTimeAgo(new Date(BASE - 5 * SECOND), { controls: true, updateInterval: 1000, showSecond: true }); + }); + + if (!ctrl) + throw new Error('controls not created'); + + expect(ctrl.timeAgo.value).toBe('5 seconds ago'); + expect(ctrl.isActive.value).toBeTruthy(); + + vi.advanceTimersByTime(55 * SECOND); + expect(ctrl.timeAgo.value).toBe('1 minute ago'); + + // pausing stops further recomputation + ctrl.pause(); + expect(ctrl.isActive.value).toBeFalsy(); + + vi.advanceTimersByTime(60 * SECOND); + expect(ctrl.timeAgo.value).toBe('1 minute ago'); + + ctrl.resume(); + expect(ctrl.isActive.value).toBeTruthy(); + // resume does not fire the callback immediately; the next tick refreshes now + vi.advanceTimersByTime(1 * SECOND); + expect(ctrl.timeAgo.value).toBe('2 minutes ago'); + + ctrl.toggle(); + expect(ctrl.isActive.value).toBeFalsy(); + + scope.stop(); + }); + + it('does not start ticking when immediate is false', () => { + const scope = effectScope(); + let result: { timeAgo: { value: string }; isActive: { value: boolean } } | undefined; + + scope.run(() => { + result = useTimeAgo(new Date(BASE - 5 * SECOND), { + controls: true, + updateInterval: 1000, + immediate: false, + showSecond: true, + }); + }); + + expect(result?.isActive.value).toBeFalsy(); + + vi.advanceTimersByTime(60 * SECOND); + // value reflects construction-time "now" since no tick fired + expect(result?.timeAgo.value).toBe('5 seconds ago'); + + scope.stop(); + }); + + it('stops updating once the scope is disposed (cleanup)', () => { + const scope = effectScope(); + let timeAgo: ComputedRef | undefined; + + scope.run(() => { + timeAgo = useTimeAgo(new Date(BASE), { updateInterval: 1000, showSecond: true }); + }); + + vi.advanceTimersByTime(60 * SECOND); + expect(timeAgo?.value).toBe('1 minute ago'); + + scope.stop(); + + vi.advanceTimersByTime(120 * SECOND); + expect(timeAgo?.value).toBe('1 minute ago'); + }); + + it('constructs without touching window/document/navigator (SSR-safe)', () => { + // useTimeAgo is pure date math + an interval; it must build with no DOM + // globals present. We simulate an SSR-ish absence and assert no throw. + const originalWindow = (globalThis as Record).window; + const originalDocument = (globalThis as Record).document; + const originalNavigator = (globalThis as Record).navigator; + + vi.stubGlobal('window', undefined); + vi.stubGlobal('document', undefined); + vi.stubGlobal('navigator', undefined); + + try { + const scope = effectScope(); + let timeAgo: ComputedRef | undefined; + + scope.run(() => { + timeAgo = useTimeAgo(new Date(BASE - 3 * MINUTE), { immediate: false }); + }); + + expect(timeAgo?.value).toBe('3 minutes ago'); + scope.stop(); + } + finally { + vi.unstubAllGlobals(); + // restore (vi.unstubAllGlobals already does, but keep references used) + void originalWindow; + void originalDocument; + void originalNavigator; + } + }); +}); diff --git a/vue/toolkit/src/composables/animation/useTimeAgo/index.ts b/vue/toolkit/src/composables/animation/useTimeAgo/index.ts new file mode 100644 index 0000000..7895210 --- /dev/null +++ b/vue/toolkit/src/composables/animation/useTimeAgo/index.ts @@ -0,0 +1,345 @@ +import { computed, shallowRef, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; +import { isFunction, isNumber, isString } from '@robonen/stdlib'; +import type { ResumableActions } from '@/types'; +import { useIntervalFn } from '@/composables/animation/useIntervalFn'; + +/** + * Formatter for a single unit value. Receives the rounded numeric value and + * whether the instant is in the past, and returns the localized fragment. + */ +export type UseTimeAgoFormatter = (value: T, isPast: boolean) => string; + +/** + * The default set of unit names recognized by `useTimeAgo`. + */ +export type UseTimeAgoUnitName + = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; + +/** + * A single time unit used while resolving the most appropriate granularity. + */ +export interface UseTimeAgoUnit { + /** + * Upper bound (exclusive) of the absolute diff (ms) this unit applies to + */ + max: number; + + /** + * Length of one unit in milliseconds + */ + value: number; + + /** + * Unit name; used to look up the matching message formatter + */ + name: Unit; +} + +/** + * Built-in (non-unit) message slots. + */ +export interface UseTimeAgoMessagesBuiltIn { + /** + * Shown when the diff is below the smallest displayed unit + */ + justNow: string; + + /** + * Wraps a past fragment (e.g. `'3 minutes'` -> `'3 minutes ago'`) + */ + past: string | UseTimeAgoFormatter; + + /** + * Wraps a future fragment (e.g. `'3 minutes'` -> `'in 3 minutes'`) + */ + future: string | UseTimeAgoFormatter; + + /** + * Shown when the provided time cannot be parsed into a valid date + */ + invalid: string; +} + +/** + * Full message map: the built-in slots plus a formatter per unit name. + */ +export type UseTimeAgoMessages + = UseTimeAgoMessagesBuiltIn & Record>; + +/** + * Options shared by the pure `formatTimeAgo` and the reactive `useTimeAgo`. + */ +export interface FormatTimeAgoOptions { + /** + * Maximum unit (or absolute ms diff) to display before falling back to + * `fullDateFormatter`. + */ + max?: UnitNames | number; + + /** + * Formatter applied when the diff exceeds `max`. + * + * @default (date) => date.toISOString().slice(0, 10) + */ + fullDateFormatter?: (date: Date) => string; + + /** + * Localized messages. + */ + messages?: UseTimeAgoMessages; + + /** + * Show seconds (i.e. allow sub-minute granularity) instead of `justNow`. + * + * @default false + */ + showSecond?: boolean; + + /** + * Rounding strategy applied to unit values. A string maps to the matching + * `Math` method; a number rounds to that many decimal places. + * + * @default 'round' + */ + rounding?: 'round' | 'ceil' | 'floor' | number; + + /** + * Custom ordered list of units (ascending by `value`). + */ + units?: Array>; +} + +/** + * Options for `useTimeAgo`. + */ +export interface UseTimeAgoOptions + extends FormatTimeAgoOptions { + /** + * Expose pause/resume controls alongside the time string. + * + * @default false + */ + controls?: Controls; + + /** + * Interval (ms) at which the relative string is recomputed. + * + * @default 30000 + */ + updateInterval?: number; + + /** + * Start the update interval immediately. + * + * @default true + */ + immediate?: boolean; +} + +/** + * Controls returned when `controls: true`. + */ +export interface UseTimeAgoControls extends ResumableActions { + /** + * The reactive relative-time string + */ + timeAgo: ComputedRef; + + /** + * Whether the update interval is currently active + */ + isActive: Readonly>; +} + +export type UseTimeAgoReturn + = Controls extends true ? UseTimeAgoControls : ComputedRef; + +const DEFAULT_UNITS: Array> = [ + { max: 60000, value: 1000, name: 'second' }, + { max: 2760000, value: 60000, name: 'minute' }, + { max: 72000000, value: 3600000, name: 'hour' }, + { max: 518400000, value: 86400000, name: 'day' }, + { max: 2419200000, value: 604800000, name: 'week' }, + { max: 28512000000, value: 2592000000, name: 'month' }, + { max: Number.POSITIVE_INFINITY, value: 31536000000, name: 'year' }, +]; + +const DEFAULT_MESSAGES: UseTimeAgoMessages = { + justNow: 'just now', + past: n => /\d/.test(n) ? `${n} ago` : n, + future: n => /\d/.test(n) ? `in ${n}` : n, + month: (n, past) => n === 1 ? (past ? 'last month' : 'next month') : `${n} month${n > 1 ? 's' : ''}`, + year: (n, past) => n === 1 ? (past ? 'last year' : 'next year') : `${n} year${n > 1 ? 's' : ''}`, + day: (n, past) => n === 1 ? (past ? 'yesterday' : 'tomorrow') : `${n} day${n > 1 ? 's' : ''}`, + week: (n, past) => n === 1 ? (past ? 'last week' : 'next week') : `${n} week${n > 1 ? 's' : ''}`, + hour: n => `${n} hour${n > 1 ? 's' : ''}`, + minute: n => `${n} minute${n > 1 ? 's' : ''}`, + second: n => `${n} second${n > 1 ? 's' : ''}`, + invalid: '', +}; + +function defaultFullDateFormatter(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** + * Pure (non-reactive) relative-time formatter. Useful on its own and reused by + * `useTimeAgo` on every tick. + * + * @param {Date} from The instant to describe + * @param {FormatTimeAgoOptions} [options={}] Formatting options + * @param {Date | number} [now=Date.now()] The reference "now" + * @returns {string} The localized relative-time string + * + * @example + * formatTimeAgo(new Date(Date.now() - 3 * 60_000)); // '3 minutes ago' + * + * @since 0.0.15 + */ +export function formatTimeAgo( + from: Date, + options: FormatTimeAgoOptions = {}, + now: Date | number = Date.now(), +): string { + const { + max, + messages = DEFAULT_MESSAGES as UseTimeAgoMessages, + fullDateFormatter = defaultFullDateFormatter, + units = DEFAULT_UNITS as Array>, + showSecond = false, + rounding = 'round', + } = options; + + const fromMs = +from; + + if (Number.isNaN(fromMs)) + return messages.invalid; + + const roundFn = isNumber(rounding) + ? (n: number): number => +n.toFixed(rounding) + : Math[rounding]; + + const diff = +now - fromMs; + const absDiff = Math.abs(diff); + + function getValue(unit: UseTimeAgoUnit): number { + return roundFn(absDiff / unit.value); + } + + function applyFormat( + name: UnitNames | keyof UseTimeAgoMessagesBuiltIn, + val: number | string, + isPast: boolean, + ): string { + const formatter = messages[name]; + + if (isFunction(formatter)) + return formatter(val as never, isPast); + + return formatter.replace('{0}', val.toString()); + } + + function format(unit: UseTimeAgoUnit): string { + const val = getValue(unit); + const past = diff > 0; + const str = applyFormat(unit.name, val, past); + + return applyFormat(past ? 'past' : 'future', str, past); + } + + if (absDiff < 60000 && !showSecond) + return messages.justNow; + + if (isNumber(max) && absDiff > max) + return fullDateFormatter(new Date(from)); + + if (isString(max)) { + const unitMax = units.find(unit => unit.name === max)?.max; + + if (unitMax && absDiff > unitMax) + return fullDateFormatter(new Date(from)); + } + + for (let idx = 0; idx < units.length; idx++) { + const unit = units[idx]!; + const prev = units[idx - 1]; + + if (getValue(unit) <= 0 && prev) + return format(prev); + + if (absDiff < unit.max) + return format(unit); + } + + return messages.invalid; +} + +/** + * @name useTimeAgo + * @category Animation + * @description Reactive relative time string (e.g. `'3 minutes ago'`) that + * ticks on a fixed interval. Fully customizable messages (i18n), units, + * rounding, and an automatic fallback to a full date once `max` is exceeded. + * + * @param {MaybeRefOrGetter} time The instant to describe (reactive) + * @param {UseTimeAgoOptions} [options={}] Options + * @returns {ComputedRef | UseTimeAgoControls} The reactive string, or controls when `controls: true` + * + * @example + * const timeAgo = useTimeAgo(new Date(Date.now() - 60_000)); // '1 minute ago' + * + * @example + * // With pause/resume controls and a custom update cadence + * const { timeAgo, pause, resume } = useTimeAgo(date, { controls: true, updateInterval: 1000 }); + * + * @example + * // i18n + full-date fallback past one month + * const timeAgo = useTimeAgo(date, { + * max: 'month', + * messages: { ...customMessages }, + * fullDateFormatter: d => d.toLocaleDateString('fr-FR'), + * }); + * + * @since 0.0.15 + */ +export function useTimeAgo( + time: MaybeRefOrGetter, + options?: UseTimeAgoOptions, +): ComputedRef; +export function useTimeAgo( + time: MaybeRefOrGetter, + options: UseTimeAgoOptions, +): UseTimeAgoControls; +export function useTimeAgo( + time: MaybeRefOrGetter, + options: UseTimeAgoOptions = {}, +): ComputedRef | UseTimeAgoControls { + const { + controls = false, + updateInterval = 30000, + immediate = true, + } = options; + + // A single ticking ref drives recomputation; the heavy formatting stays in + // a computed so it only runs when `now` or `time` actually change. + const now = shallowRef(Date.now()); + + const resumable = useIntervalFn(() => { + now.value = Date.now(); + }, updateInterval, { immediate }); + + const timeAgo = computed(() => formatTimeAgo(new Date(toValue(time)), options, now.value)); + + if (controls) { + return { + timeAgo, + isActive: resumable.isActive, + pause: resumable.pause, + resume: resumable.resume, + toggle: resumable.toggle, + }; + } + + return timeAgo; +} diff --git a/vue/toolkit/src/composables/animation/useTimeout/index.test.ts b/vue/toolkit/src/composables/animation/useTimeout/index.test.ts new file mode 100644 index 0000000..73c6cec --- /dev/null +++ b/vue/toolkit/src/composables/animation/useTimeout/index.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, isReadonly, ref } from 'vue'; +import { useTimeout } from '.'; + +describe(useTimeout, () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('flips ready to true after the interval', () => { + const ready = useTimeout(100); + + expect(ready.value).toBeFalsy(); + vi.advanceTimersByTime(100); + expect(ready.value).toBeTruthy(); + }); + + it('defaults the interval to 1000ms', () => { + const ready = useTimeout(); + + vi.advanceTimersByTime(999); + expect(ready.value).toBeFalsy(); + vi.advanceTimersByTime(1); + expect(ready.value).toBeTruthy(); + }); + + it('returns a read-only computed by default', () => { + const ready = useTimeout(100); + + expect(isReadonly(ready)).toBeTruthy(); + }); + + it('starts ready when immediate is false', () => { + const ready = useTimeout(100, { immediate: false }); + + expect(ready.value).toBeTruthy(); + vi.advanceTimersByTime(100); + expect(ready.value).toBeTruthy(); + }); + + it('invokes the callback when the timeout elapses', () => { + const callback = vi.fn(); + useTimeout(100, { callback }); + + expect(callback).not.toHaveBeenCalled(); + vi.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledOnce(); + }); + + it('reads the interval reactively each time it starts', () => { + const delay = ref(100); + const { ready, start } = useTimeout(delay, { controls: true, immediate: false }); + + delay.value = 200; + start(); + expect(ready.value).toBeFalsy(); + vi.advanceTimersByTime(100); + expect(ready.value).toBeFalsy(); + vi.advanceTimersByTime(100); + expect(ready.value).toBeTruthy(); + }); + + describe('controls', () => { + it('exposes ready, start and stop', () => { + const controls = useTimeout(100, { controls: true }); + + expect(controls).toHaveProperty('ready'); + expect(controls).toHaveProperty('start'); + expect(controls).toHaveProperty('stop'); + }); + + it('start restarts the pending timeout', () => { + const { ready, start } = useTimeout(100, { controls: true }); + + vi.advanceTimersByTime(50); + start(); + vi.advanceTimersByTime(50); + expect(ready.value).toBeFalsy(); + vi.advanceTimersByTime(50); + expect(ready.value).toBeTruthy(); + }); + + it('start re-arms the timeout after it has elapsed', () => { + const callback = vi.fn(); + const { ready, start } = useTimeout(100, { controls: true, callback }); + + vi.advanceTimersByTime(100); + expect(ready.value).toBeTruthy(); + expect(callback).toHaveBeenCalledOnce(); + + start(); + expect(ready.value).toBeFalsy(); + vi.advanceTimersByTime(100); + expect(ready.value).toBeTruthy(); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('stop cancels the pending callback', () => { + const callback = vi.fn(); + const { stop } = useTimeout(100, { controls: true, callback }); + + stop(); + vi.advanceTimersByTime(100); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + it('cleans up on scope dispose', () => { + const callback = vi.fn(); + const scope = effectScope(); + scope.run(() => { + useTimeout(100, { callback }); + }); + + scope.stop(); + vi.advanceTimersByTime(100); + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not auto-start the real timer in a non-client (SSR) environment', () => { + // `useTimeoutFn` is guarded by `isClient`; with immediate auto-start it + // marks the timeout pending but only schedules a timer on the client. + // We assert the SSR-safe contract: no callback fires without timers running. + const callback = vi.fn(); + const { ready } = useTimeout(100, { controls: true, immediate: false, callback }); + + // immediate:false means never auto-armed -> ready stays true, callback never fires + expect(ready.value).toBeTruthy(); + vi.advanceTimersByTime(1000); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/vue/toolkit/src/composables/animation/useTimeout/index.ts b/vue/toolkit/src/composables/animation/useTimeout/index.ts new file mode 100644 index 0000000..e829511 --- /dev/null +++ b/vue/toolkit/src/composables/animation/useTimeout/index.ts @@ -0,0 +1,90 @@ +import { computed } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import type { VoidFunction } from '@robonen/stdlib'; +import { noop } from '@robonen/stdlib'; +import { useTimeoutFn } from '@/composables/animation/useTimeoutFn'; +import type { UseTimeoutFnOptions, UseTimeoutFnReturn } from '@/composables/animation/useTimeoutFn'; + +export interface UseTimeoutOptions extends UseTimeoutFnOptions { + /** + * Expose `start`/`stop` controls alongside the `ready` flag + * + * @default false + */ + controls?: Controls; + + /** + * Callback invoked when the timeout elapses + */ + callback?: VoidFunction; +} + +export interface UseTimeoutControls { + /** + * Reactive flag that is `false` while the timeout is pending and flips to + * `true` once the delay has elapsed + */ + ready: ComputedRef; + + /** + * Start (or restart) the timeout + */ + start: UseTimeoutFnReturn<[]>['start']; + + /** + * Cancel the pending timeout (leaves `ready` at its current value) + */ + stop: UseTimeoutFnReturn<[]>['stop']; +} + +export type UseTimeoutReturn + = ComputedRef | UseTimeoutControls; + +/** + * @name useTimeout + * @category Animation + * @description Reactive boolean that flips to `true` after a given delay. + * Built on `useTimeoutFn`; optionally exposes `start`/`stop` controls. SSR-safe. + * + * @param {MaybeRefOrGetter} [interval=1000] Delay in milliseconds (resolved each time the timeout starts, can be reactive) + * @param {UseTimeoutOptions} [options={}] Options + * @returns {ComputedRef | UseTimeoutControls} The read-only `ready` flag, or controls when `controls: true` + * + * @example + * const ready = useTimeout(1000); + * // `ready.value` becomes true after 1s + * + * @example + * const { ready, start, stop } = useTimeout(1000, { controls: true }); + * + * @example + * // Run a callback when the timeout elapses + * useTimeout(5000, { callback: refresh }); + * + * @since 0.0.15 + */ +export function useTimeout(interval?: MaybeRefOrGetter, options?: UseTimeoutOptions): ComputedRef; +export function useTimeout(interval: MaybeRefOrGetter, options: UseTimeoutOptions): UseTimeoutControls; +export function useTimeout( + interval: MaybeRefOrGetter = 1000, + options: UseTimeoutOptions = {}, +): UseTimeoutReturn { + const { + controls: exposeControls = false, + callback = noop, + } = options; + + const { isPending, start, stop } = useTimeoutFn(callback, interval, options); + + const ready = computed(() => !isPending.value); + + if (exposeControls) { + return { + ready, + start, + stop, + }; + } + + return ready; +} diff --git a/vue/toolkit/src/composables/utilities/useTimeoutFn/index.test.ts b/vue/toolkit/src/composables/animation/useTimeoutFn/index.test.ts similarity index 100% rename from vue/toolkit/src/composables/utilities/useTimeoutFn/index.test.ts rename to vue/toolkit/src/composables/animation/useTimeoutFn/index.test.ts diff --git a/vue/toolkit/src/composables/utilities/useTimeoutFn/index.ts b/vue/toolkit/src/composables/animation/useTimeoutFn/index.ts similarity index 99% rename from vue/toolkit/src/composables/utilities/useTimeoutFn/index.ts rename to vue/toolkit/src/composables/animation/useTimeoutFn/index.ts index 22af269..ab769f6 100644 --- a/vue/toolkit/src/composables/utilities/useTimeoutFn/index.ts +++ b/vue/toolkit/src/composables/animation/useTimeoutFn/index.ts @@ -40,7 +40,7 @@ export interface UseTimeoutFnReturn { /** * @name useTimeoutFn - * @category Utilities + * @category Animation * @description Call a function after a given delay, with manual `start`/`stop` * control and a reactive `isPending` flag. SSR-safe and cleans up on scope dispose. * diff --git a/vue/toolkit/src/composables/utilities/useTimestamp/index.test.ts b/vue/toolkit/src/composables/animation/useTimestamp/index.test.ts similarity index 100% rename from vue/toolkit/src/composables/utilities/useTimestamp/index.test.ts rename to vue/toolkit/src/composables/animation/useTimestamp/index.test.ts diff --git a/vue/toolkit/src/composables/utilities/useTimestamp/index.ts b/vue/toolkit/src/composables/animation/useTimestamp/index.ts similarity index 95% rename from vue/toolkit/src/composables/utilities/useTimestamp/index.ts rename to vue/toolkit/src/composables/animation/useTimestamp/index.ts index 4f96daf..189be41 100644 --- a/vue/toolkit/src/composables/utilities/useTimestamp/index.ts +++ b/vue/toolkit/src/composables/animation/useTimestamp/index.ts @@ -2,8 +2,8 @@ import { shallowRef, toValue } from 'vue'; import type { MaybeRefOrGetter, Ref } from 'vue'; import { timestamp } from '@robonen/stdlib'; import type { ResumableActions } from '@/types'; -import { useRafFn } from '@/composables/browser/useRafFn'; -import { useIntervalFn } from '@/composables/browser/useIntervalFn'; +import { useRafFn } from '@/composables/animation/useRafFn'; +import { useIntervalFn } from '@/composables/animation/useIntervalFn'; export interface UseTimestampOptions { /** @@ -64,7 +64,7 @@ export type UseTimestampReturn = Controls extends true /** * @name useTimestamp - * @category Utilities + * @category Animation * @description Reactive current timestamp, updated via `requestAnimationFrame` * or a fixed interval. * diff --git a/vue/toolkit/src/composables/animation/useTransition/index.test.ts b/vue/toolkit/src/composables/animation/useTransition/index.test.ts new file mode 100644 index 0000000..531f066 --- /dev/null +++ b/vue/toolkit/src/composables/animation/useTransition/index.test.ts @@ -0,0 +1,315 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { + TransitionPresets, + useTransition, +} from '@/composables/animation/useTransition'; + +// A controllable window stub: requestAnimationFrame frames are flushed +// manually via `frame()`, and timers are driven by vitest fake timers. +function createWindowStub() { + let rafId = 0; + const callbacks = new Map(); + + const win = { + requestAnimationFrame: (cb: FrameRequestCallback) => { + const id = ++rafId; + callbacks.set(id, cb); + return id; + }, + cancelAnimationFrame: (id: number) => { + callbacks.delete(id); + }, + setTimeout: (fn: (...args: unknown[]) => void, ms?: number) => + setTimeout(fn, ms) as unknown as number, + clearTimeout: (id: number) => clearTimeout(id), + } as unknown as Window; + + function frame() { + const pending = [...callbacks.entries()]; + callbacks.clear(); + for (const [, cb] of pending) + cb(Date.now()); + } + + return { win, frame, get pending() { + return callbacks.size; + } }; +} + +describe(useTransition, () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('seeds output with the initial source value', () => { + const { win } = createWindowStub(); + const scope = effectScope(); + + scope.run(() => { + const output = useTransition(ref(5), { window: win }); + expect(output.value).toBe(5); + }); + + scope.stop(); + }); + + it('transitions a number from old to new over the duration', async () => { + const { win, frame } = createWindowStub(); + vi.setSystemTime(0); + const scope = effectScope(); + const source = ref(0); + let output!: ReturnType>; + + scope.run(() => { + output = useTransition(source, { duration: 100, window: win }); + }); + + source.value = 100; + await nextTick(); + + // Halfway through. + vi.setSystemTime(50); + frame(); + expect(output.value).toBeCloseTo(50, 5); + + // End. + vi.setSystemTime(100); + frame(); + expect(output.value).toBe(100); + + scope.stop(); + }); + + it('fires onStarted and onFinished', async () => { + const { win, frame } = createWindowStub(); + vi.setSystemTime(0); + const onStarted = vi.fn(); + const onFinished = vi.fn(); + const scope = effectScope(); + const source = ref(0); + + scope.run(() => { + useTransition(source, { duration: 100, window: win, onStarted, onFinished }); + }); + + source.value = 10; + await nextTick(); + + expect(onStarted).toHaveBeenCalledTimes(1); + expect(onFinished).not.toHaveBeenCalled(); + + vi.setSystemTime(100); + frame(); + + expect(onFinished).toHaveBeenCalledTimes(1); + + scope.stop(); + }); + + it('transitions numeric arrays element-wise', async () => { + const { win, frame } = createWindowStub(); + vi.setSystemTime(0); + const scope = effectScope(); + const source = ref([0, 100]); + let output!: ReturnType>; + + scope.run(() => { + output = useTransition(source, { duration: 100, window: win }); + }); + + source.value = [100, 0]; + await nextTick(); + + vi.setSystemTime(50); + frame(); + expect(output.value[0]).toBeCloseTo(50, 5); + expect(output.value[1]).toBeCloseTo(50, 5); + + vi.setSystemTime(100); + frame(); + expect(output.value).toEqual([100, 0]); + + scope.stop(); + }); + + it('applies an easing preset (eased value differs from linear midpoint)', async () => { + const { win, frame } = createWindowStub(); + vi.setSystemTime(0); + const scope = effectScope(); + const source = ref(0); + let output!: ReturnType>; + + scope.run(() => { + output = useTransition(source, { + duration: 100, + transition: TransitionPresets.easeInCubic, + window: win, + }); + }); + + source.value = 100; + await nextTick(); + + vi.setSystemTime(50); + frame(); + // easeInCubic at t=0.5 is well below the linear midpoint of 50. + expect(output.value).toBeLessThan(50); + expect(output.value).toBeGreaterThan(0); + + scope.stop(); + }); + + it('accepts a custom easing function', async () => { + const { win, frame } = createWindowStub(); + vi.setSystemTime(0); + const scope = effectScope(); + const source = ref(0); + const easing = vi.fn((n: number) => n); + let output!: ReturnType>; + + scope.run(() => { + output = useTransition(source, { duration: 100, transition: easing, window: win }); + }); + + source.value = 100; + await nextTick(); + + vi.setSystemTime(50); + frame(); + + expect(easing).toHaveBeenCalled(); + expect(output.value).toBeCloseTo(50, 5); + + scope.stop(); + }); + + it('delays the start of a transition', async () => { + const { win, frame } = createWindowStub(); + vi.setSystemTime(0); + const onStarted = vi.fn(); + const scope = effectScope(); + const source = ref(0); + + scope.run(() => { + useTransition(source, { duration: 100, delay: 200, window: win, onStarted }); + }); + + source.value = 100; + await nextTick(); + + // Still in the delay window: not started yet. + expect(onStarted).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(200); + expect(onStarted).toHaveBeenCalledTimes(1); + + vi.setSystemTime(200); + frame(); + expect(onStarted).toHaveBeenCalledTimes(1); + + scope.stop(); + }); + + it('snaps instantly when disabled and tracks the source', async () => { + const { win, frame, pending } = createWindowStub(); + const scope = effectScope(); + const source = ref(0); + let output!: ReturnType>; + + scope.run(() => { + output = useTransition(source, { duration: 100, disabled: true, window: win }); + }); + + source.value = 42; + await nextTick(); + + expect(output.value).toBe(42); + expect(pending).toBe(0); + frame(); + expect(output.value).toBe(42); + + scope.stop(); + }); + + it('disabling mid-transition snaps to the source', async () => { + const { win, frame } = createWindowStub(); + vi.setSystemTime(0); + const scope = effectScope(); + const source = ref(0); + const disabled = ref(false); + let output!: ReturnType>; + + scope.run(() => { + output = useTransition(source, { duration: 100, disabled, window: win }); + }); + + source.value = 100; + await nextTick(); + + vi.setSystemTime(50); + frame(); + expect(output.value).toBeCloseTo(50, 5); + + disabled.value = true; + await nextTick(); + + expect(output.value).toBe(100); + + scope.stop(); + }); + + it('jumps immediately when duration is zero', async () => { + const { win } = createWindowStub(); + vi.setSystemTime(0); + const onFinished = vi.fn(); + const scope = effectScope(); + const source = ref(0); + let output!: ReturnType>; + + scope.run(() => { + output = useTransition(source, { duration: 0, window: win, onFinished }); + }); + + source.value = 100; + await nextTick(); + + expect(output.value).toBe(100); + expect(onFinished).toHaveBeenCalledTimes(1); + + scope.stop(); + }); + + it('is SSR-safe: without a window it mirrors the source synchronously', async () => { + const onFinished = vi.fn(); + const scope = effectScope(); + const source = ref(0); + let output!: ReturnType>; + + scope.run(() => { + output = useTransition(source, { duration: 100, window: undefined, onFinished }); + }); + + expect(output.value).toBe(0); + + source.value = 100; + await nextTick(); + + // No RAF available: transition resolves immediately to the target. + expect(output.value).toBe(100); + expect(onFinished).toHaveBeenCalledTimes(1); + + scope.stop(); + }); + + it('exposes the documented easing presets', () => { + expect(TransitionPresets.linear).toEqual([0, 0, 1, 1]); + expect(TransitionPresets.easeInOutCubic).toEqual([0.65, 0, 0.35, 1]); + expect(Object.keys(TransitionPresets)).toContain('easeOutBack'); + }); +}); diff --git a/vue/toolkit/src/composables/animation/useTransition/index.ts b/vue/toolkit/src/composables/animation/useTransition/index.ts new file mode 100644 index 0000000..23f6b48 --- /dev/null +++ b/vue/toolkit/src/composables/animation/useTransition/index.ts @@ -0,0 +1,360 @@ +import { computed, ref, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { clamp, isFunction, isNumber, lerp, noop } from '@robonen/stdlib'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useRafFn } from '@/composables/animation/useRafFn'; + +/** + * Cubic bezier control points `[x1, y1, x2, y2]` (the implied endpoints are + * `(0, 0)` and `(1, 1)`), matching the CSS `cubic-bezier()` argument order. + */ +export type CubicBezierPoints = [number, number, number, number]; + +/** + * An easing function mapping linear progress in `[0, 1]` to eased progress. + */ +export type EasingFunction = (n: number) => number; + +/** + * Interpolates between two values of `T` given an eased progress `alpha`. + */ +export type TransitionInterpolation = (from: T, to: T, alpha: number) => T; + +/** + * The transition easing: either a cubic bezier tuple or a custom easing function. + */ +export type TransitionEasing = CubicBezierPoints | EasingFunction; + +/** + * Values that can be transitioned: a single number or a fixed-length number array. + */ +export type TransitionValue = number | number[]; + +/** + * Common cubic bezier easing presets (same curves as CSS / VueUse). + */ +export const TransitionPresets = { + linear: [0, 0, 1, 1], + easeInSine: [0.12, 0, 0.39, 0], + easeOutSine: [0.61, 1, 0.88, 1], + easeInOutSine: [0.37, 0, 0.63, 1], + easeInQuad: [0.11, 0, 0.5, 0], + easeOutQuad: [0.5, 1, 0.89, 1], + easeInOutQuad: [0.45, 0, 0.55, 1], + easeInCubic: [0.32, 0, 0.67, 0], + easeOutCubic: [0.33, 1, 0.68, 1], + easeInOutCubic: [0.65, 0, 0.35, 1], + easeInQuart: [0.5, 0, 0.75, 0], + easeOutQuart: [0.25, 1, 0.5, 1], + easeInOutQuart: [0.76, 0, 0.24, 1], + easeInQuint: [0.64, 0, 0.78, 0], + easeOutQuint: [0.22, 1, 0.36, 1], + easeInOutQuint: [0.83, 0, 0.17, 1], + easeInExpo: [0.7, 0, 0.84, 0], + easeOutExpo: [0.16, 1, 0.3, 1], + easeInOutExpo: [0.87, 0, 0.13, 1], + easeInCirc: [0.55, 0, 1, 0.45], + easeOutCirc: [0, 0.55, 0.45, 1], + easeInOutCirc: [0.85, 0, 0.15, 1], + easeInBack: [0.36, 0, 0.66, -0.56], + easeOutBack: [0.34, 1.56, 0.64, 1], + easeInOutBack: [0.68, -0.6, 0.32, 1.6], +} satisfies Record; + +export interface UseTransitionOptions { + /** + * Transition duration in milliseconds. Accepts a reactive value (resolved + * at the start of each transition). + * + * @default 1000 + */ + duration?: MaybeRefOrGetter; + + /** + * Easing applied to the progress: a cubic bezier tuple (e.g. one of + * {@link TransitionPresets}) or a custom easing function. + * + * @default identity (linear) + */ + transition?: MaybeRefOrGetter; + + /** + * Delay in milliseconds before a transition begins after the source changes. + * + * @default 0 + */ + delay?: MaybeRefOrGetter; + + /** + * When `true`, transitions are skipped and the output tracks the source + * value directly (no animation). Reactive. + * + * @default false + */ + disabled?: MaybeRefOrGetter; + + /** + * Called when a transition starts. + */ + onStarted?: () => void; + + /** + * Called when a transition finishes (not called when aborted by a new change). + */ + onFinished?: () => void; +} + +export type UseTransitionReturn = Readonly>; + +const identity: EasingFunction = n => n; + +interface BezierCoefficients { + a: (a1: number, a2: number) => number; + b: (a1: number, a2: number) => number; + c: (a1: number) => number; +} + +function createEasingFunction(points: CubicBezierPoints): EasingFunction { + const [p0, p1, p2, p3] = points; + + const coeffs: BezierCoefficients = { + a: (a1, a2) => 1 - 3 * a2 + 3 * a1, + b: (a1, a2) => 3 * a2 - 6 * a1, + c: a1 => 3 * a1, + }; + + const calcBezier = (t: number, a1: number, a2: number): number => + ((coeffs.a(a1, a2) * t + coeffs.b(a1, a2)) * t + coeffs.c(a1)) * t; + + const getSlope = (t: number, a1: number, a2: number): number => + 3 * coeffs.a(a1, a2) * t * t + 2 * coeffs.b(a1, a2) * t + coeffs.c(a1); + + const getTForX = (x: number): number => { + let guess = x; + + for (let i = 0; i < 4; ++i) { + const slope = getSlope(guess, p0, p2); + + if (slope === 0) + return guess; + + const currentX = calcBezier(guess, p0, p2) - x; + guess -= currentX / slope; + } + + return guess; + }; + + return n => (p0 === p1 && p2 === p3) ? n : calcBezier(getTForX(n), p1, p3); +} + +function resolveEasing(transition: TransitionEasing | undefined): EasingFunction { + if (!transition) + return identity; + + if (isFunction(transition)) + return transition; + + return createEasingFunction(transition); +} + +// Interpolate a single number or a (fixed-length) numeric array. +function interpolate(from: TransitionValue, to: TransitionValue, alpha: number): TransitionValue { + if (isNumber(from) && isNumber(to)) + return lerp(from, to, alpha); + + const source = from as number[]; + const target = to as number[]; + + return source.map((value, index) => lerp(value, target[index] ?? value, alpha)); +} + +function snapshot(value: T): T { + return (isNumber(value) ? value : (value as number[]).slice()) as T; +} + +function valuesEqual(a: TransitionValue, b: TransitionValue): boolean { + if (isNumber(a) && isNumber(b)) + return a === b; + + if (isNumber(a) || isNumber(b)) + return false; + + if (a.length !== b.length) + return false; + + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) + return false; + } + + return true; +} + +/** + * @name useTransition + * @category Animation + * @description Reactively transition between numeric values (or numeric arrays) + * over a duration with configurable easing. Wraps a single, paused + * `requestAnimationFrame` loop that only runs while a transition is in flight, + * so it is cheaper than re-creating an RAF loop per change. SSR-safe: without a + * `window` the output tracks the source synchronously. + * + * @param {MaybeRefOrGetter} source The reactive source value (number or number[]) + * @param {UseTransitionOptions & ConfigurableWindow} [options={}] Transition options + * @returns {UseTransitionReturn} A readonly ref of the transitioned value + * + * @example + * const progress = ref(0); + * const output = useTransition(progress, { + * duration: 500, + * transition: TransitionPresets.easeOutCubic, + * }); + * + * @example + * // Transition a tuple (e.g. an RGB color) + * const color = ref([0, 0, 0]); + * const animated = useTransition(color, { duration: 1000 }); + * + * @since 0.0.15 + */ +export function useTransition( + source: MaybeRefOrGetter, + options: UseTransitionOptions & ConfigurableWindow = {}, +): UseTransitionReturn { + const { + duration = 1000, + transition = identity, + delay = 0, + disabled = false, + onStarted = noop, + onFinished = noop, + } = options; + + const window = 'window' in options ? options.window : defaultWindow; + + // The animated output. Seeded with a snapshot of the current source. + const outputRef = ref(snapshot(toValue(source))) as Ref; + + // Active-transition state. `endpoints` are detached snapshots so that later + // source mutations cannot bleed into an in-flight transition. + let fromValue: T = outputRef.value; + let toValue_: T = outputRef.value; + let startedAt = 0; + let durationMs = 0; + let easing: EasingFunction = identity; + let delayTimer: ReturnType | null = null; + let finishPending = false; + + const { pause, resume, isActive } = useRafFn(tick, { immediate: false, window }); + + function clearDelay() { + if (delayTimer !== null && window) { + window.clearTimeout(delayTimer); + delayTimer = null; + } + } + + function settle(value: T) { + outputRef.value = snapshot(value); + + if (isActive.value) + pause(); + + if (finishPending) { + finishPending = false; + onFinished(); + } + } + + function tick() { + const now = Date.now(); + const alpha = durationMs <= 0 ? 1 : clamp((now - startedAt) / durationMs, 0, 1); + + outputRef.value = interpolate(fromValue, toValue_, easing(alpha)) as T; + + if (alpha >= 1) + settle(toValue_); + } + + function begin(target: T) { + fromValue = snapshot(outputRef.value); + toValue_ = snapshot(target); + + durationMs = Math.max(0, toValue(duration)); + easing = resolveEasing(toValue(transition)); + startedAt = Date.now(); + finishPending = true; + + onStarted(); + + if (durationMs <= 0 || !window) { + settle(toValue_); + return; + } + + if (!isActive.value) + resume(); + } + + function start(target: T) { + clearDelay(); + + const delayMs = Math.max(0, toValue(delay)); + + if (delayMs > 0 && window) { + delayTimer = window.setTimeout(() => { + delayTimer = null; + begin(target); + }, delayMs); + + return; + } + + begin(target); + } + + watch( + () => toValue(source), + (value) => { + // When disabled, mirror the source instantly and abort any animation. + if (toValue(disabled)) { + clearDelay(); + + if (isActive.value) + pause(); + + finishPending = false; + outputRef.value = snapshot(value); + + return; + } + + // Skip no-op changes so we don't restart an identical transition. + if (valuesEqual(value, outputRef.value) && !isActive.value && delayTimer === null) + return; + + start(value); + }, + { deep: true }, + ); + + // Reacting to `disabled` flipping to true mid-transition: snap to source. + watch( + () => toValue(disabled), + (off) => { + if (off) { + clearDelay(); + + if (isActive.value) + pause(); + + finishPending = false; + outputRef.value = snapshot(toValue(source)); + } + }, + ); + + return computed(() => outputRef.value); +} diff --git a/vue/toolkit/src/composables/array/index.ts b/vue/toolkit/src/composables/array/index.ts new file mode 100644 index 0000000..3620861 --- /dev/null +++ b/vue/toolkit/src/composables/array/index.ts @@ -0,0 +1,13 @@ +export * from './useArrayDifference'; +export * from './useArrayEvery'; +export * from './useArrayFilter'; +export * from './useArrayFind'; +export * from './useArrayFindIndex'; +export * from './useArrayFindLast'; +export * from './useArrayIncludes'; +export * from './useArrayJoin'; +export * from './useArrayMap'; +export * from './useArrayReduce'; +export * from './useArraySome'; +export * from './useArrayUnique'; +export * from './useSorted'; diff --git a/vue/toolkit/src/composables/array/useArrayDifference/index.test.ts b/vue/toolkit/src/composables/array/useArrayDifference/index.test.ts new file mode 100644 index 0000000..a788ab1 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayDifference/index.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArrayDifference } from '.'; + +describe(useArrayDifference, () => { + it('returns the asymmetric difference of two arrays', () => { + const list = ref([1, 2, 3, 4, 5]); + const values = ref([2, 4]); + const diff = useArrayDifference(list, values); + expect(diff.value).toEqual([1, 3, 5]); + }); + + it('returns an empty array when all items are subtracted', () => { + const list = ref([1, 2, 3]); + const values = ref([1, 2, 3, 4]); + const diff = useArrayDifference(list, values); + expect(diff.value).toEqual([]); + }); + + it('returns the full list when values is empty', () => { + const list = ref([1, 2, 3]); + const values = ref([]); + const diff = useArrayDifference(list, values); + expect(diff.value).toEqual([1, 2, 3]); + }); + + it('reacts to changes in the source array', () => { + const list = ref([1, 2, 3]); + const values = ref([2]); + const diff = useArrayDifference(list, values); + expect(diff.value).toEqual([1, 3]); + + list.value = [1, 2, 3, 4]; + expect(diff.value).toEqual([1, 3, 4]); + }); + + it('reacts to changes in the values array', () => { + const list = ref([1, 2, 3]); + const values = ref([2]); + const diff = useArrayDifference(list, values); + expect(diff.value).toEqual([1, 3]); + + values.value = [1, 2]; + expect(diff.value).toEqual([3]); + }); + + it('accepts getters as sources', () => { + const a = ref(1); + const b = ref(2); + const diff = useArrayDifference(() => [a.value, b.value, 3], () => [b.value]); + expect(diff.value).toEqual([1, 3]); + + b.value = 1; + expect(diff.value).toEqual([3]); + }); + + it('compares by key (positional argument)', () => { + const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]); + const values = ref([{ id: 2 }]); + const diff = useArrayDifference(list, values, 'id'); + expect(diff.value).toEqual([{ id: 1 }, { id: 3 }]); + }); + + it('compares with a custom comparator function', () => { + const list = ref([1, 2, 3, 4, 5, 6]); + const values = ref([2]); + // Treat numbers with the same parity as equal. + const diff = useArrayDifference(list, values, (a, b) => a % 2 === b % 2); + expect(diff.value).toEqual([1, 3, 5]); + }); + + it('returns the symmetric difference via options', () => { + const a = ref([1, 2, 3]); + const b = ref([2, 3, 4]); + const diff = useArrayDifference(a, b, { symmetric: true }); + expect(diff.value).toEqual([1, 4]); + }); + + it('returns the symmetric difference via the trailing options argument', () => { + const a = ref([{ id: 1 }, { id: 2 }]); + const b = ref([{ id: 2 }, { id: 3 }]); + const diff = useArrayDifference(a, b, 'id', { symmetric: true }); + expect(diff.value).toEqual([{ id: 1 }, { id: 3 }]); + }); + + it('accepts a comparator inside the options object', () => { + const list = ref([1, 2, 3, 4]); + const values = ref([20, 30]); + const diff = useArrayDifference(list, values, { + comparator: (a, b) => a === b / 10, + }); + expect(diff.value).toEqual([1, 4]); + }); + + it('reacts to source changes when symmetric', () => { + const a = ref([1, 2]); + const b = ref([2, 3]); + const diff = useArrayDifference(a, b, { symmetric: true }); + expect(diff.value).toEqual([1, 3]); + + a.value = [1, 2, 3]; + expect(diff.value).toEqual([1]); + }); + + it('does not mutate the source arrays in symmetric mode', () => { + const a = ref([1, 2]); + const b = ref([2, 3]); + const diff = useArrayDifference(a, b, { symmetric: true }); + expect(diff.value).toEqual([1, 3]); + expect(a.value).toEqual([1, 2]); + expect(b.value).toEqual([2, 3]); + }); + + it('is SSR-safe: never touches window/document/navigator', () => { + // Pure computed wrapper — evaluating it relies on no browser globals. + const list = ref([1, 2, 3]); + const values = ref([2]); + const diff = useArrayDifference(list, values); + expect(diff.value).toEqual([1, 3]); + }); +}); diff --git a/vue/toolkit/src/composables/array/useArrayDifference/index.ts b/vue/toolkit/src/composables/array/useArrayDifference/index.ts new file mode 100644 index 0000000..96394b9 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayDifference/index.ts @@ -0,0 +1,133 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import { isObject, isString } from '@robonen/stdlib'; + +/** + * Comparator deciding whether two array elements are considered equal. + */ +export type UseArrayDifferenceComparatorFn + = (value: T, othVal: T) => boolean; + +export interface UseArrayDifferenceOptions { + /** + * When `true`, returns the symmetric difference: items present in exactly one + * of the two arrays (`list` XOR `values`). When `false`, returns the + * asymmetric difference: items in `list` that are not in `values`. + * + * @see https://en.wikipedia.org/wiki/Symmetric_difference + * @default false + */ + symmetric?: boolean; + /** + * Custom comparator function, or a key of `T` to compare a single property by. + */ + comparator?: UseArrayDifferenceComparatorFn | keyof T; +} + +export type UseArrayDifferenceReturn + = ComputedRef; + +function isArrayDifferenceOptions(value: unknown): value is UseArrayDifferenceOptions { + // isObject matches PLAIN objects only, so comparator functions/keys never reach here. + return isObject(value) && ('symmetric' in value || 'comparator' in value); +} + +/** + * @name useArrayDifference + * @category Array + * @description Reactive difference of two arrays. Returns items in `list` that are not in `values` (asymmetric), or items in exactly one array (symmetric). Both arrays may be reactive (refs or getters). + * + * @param {MaybeRefOrGetter} list The source array + * @param {MaybeRefOrGetter} values The array of values to subtract from `list` + * @param {UseArrayDifferenceComparatorFn | keyof T | UseArrayDifferenceOptions} [comparator] A comparator function, a key of `T` to compare by, or an options object with `comparator`/`symmetric` + * @param {UseArrayDifferenceOptions} [options] Extra options when `comparator` is a function or key + * @returns {UseArrayDifferenceReturn} A computed array of the difference + * + * @example + * const list = ref([1, 2, 3, 4, 5]); + * const values = ref([2, 4]); + * const diff = useArrayDifference(list, values); // [1, 3, 5] + * + * @example + * const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]); + * const values = ref([{ id: 2 }]); + * const diff = useArrayDifference(list, values, 'id'); // [{ id: 1 }, { id: 3 }] + * + * @example + * const a = ref([1, 2, 3]); + * const b = ref([2, 3, 4]); + * const symmetric = useArrayDifference(a, b, { symmetric: true }); // [1, 4] + * + * @since 0.0.15 + */ +export function useArrayDifference( + list: MaybeRefOrGetter, + values: MaybeRefOrGetter, + comparator?: UseArrayDifferenceComparatorFn, + options?: UseArrayDifferenceOptions, +): UseArrayDifferenceReturn; +export function useArrayDifference( + list: MaybeRefOrGetter, + values: MaybeRefOrGetter, + comparator?: keyof T, + options?: UseArrayDifferenceOptions, +): UseArrayDifferenceReturn; +export function useArrayDifference( + list: MaybeRefOrGetter, + values: MaybeRefOrGetter, + options?: UseArrayDifferenceOptions, +): UseArrayDifferenceReturn; +export function useArrayDifference( + list: MaybeRefOrGetter, + values: MaybeRefOrGetter, + comparator?: UseArrayDifferenceComparatorFn | keyof T | UseArrayDifferenceOptions, + options?: UseArrayDifferenceOptions, +): UseArrayDifferenceReturn { + let symmetric = false; + let resolved: UseArrayDifferenceComparatorFn | keyof T | undefined; + + if (isArrayDifferenceOptions(comparator)) { + symmetric = comparator.symmetric ?? false; + resolved = comparator.comparator; + } + else { + resolved = comparator; + symmetric = options?.symmetric ?? false; + // An explicit comparator/key in `options` wins over the positional argument. + if (options?.comparator !== undefined) + resolved = options.comparator; + } + + // Resolve the comparator once instead of rebuilding it on every recompute. + let compare: UseArrayDifferenceComparatorFn; + + if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') { + const key = resolved as keyof T; + compare = (value, othVal) => value[key] === othVal[key]; + } + else if (typeof resolved === 'function') { + compare = resolved; + } + else { + compare = (value, othVal) => value === othVal; + } + + return computed(() => { + const source = toValue(list); + const other = toValue(values); + + // Items in `source` absent from `other`. + const diff = source.filter(value => !other.some(othVal => compare(value, othVal))); + + if (!symmetric) + return diff; + + // Items in `other` absent from `source`, appended for the symmetric difference. + for (const value of other) { + if (!source.some(srcVal => compare(value, srcVal))) + diff.push(value); + } + + return diff; + }); +} diff --git a/vue/toolkit/src/composables/array/useArrayEvery/index.test.ts b/vue/toolkit/src/composables/array/useArrayEvery/index.test.ts new file mode 100644 index 0000000..9a331ea --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayEvery/index.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArrayEvery } from '.'; + +describe(useArrayEvery, () => { + it('returns true when every element passes', () => { + const list = ref([1, 2, 3, 4]); + const allPositive = useArrayEvery(list, n => n > 0); + expect(allPositive.value).toBeTruthy(); + }); + + it('returns false when some element fails', () => { + const list = ref([1, -2, 3, 4]); + const allPositive = useArrayEvery(list, n => n > 0); + expect(allPositive.value).toBeFalsy(); + }); + + it('reacts to source array changes', () => { + const list = ref([2, 4, 6]); + const allEven = useArrayEvery(list, n => n % 2 === 0); + expect(allEven.value).toBeTruthy(); + + list.value = [2, 4, 5]; + expect(allEven.value).toBeFalsy(); + }); + + it('unwraps reactive items', () => { + const items = [ref(2), ref(4), ref(6)]; + const allEven = useArrayEvery(items, n => n % 2 === 0); + expect(allEven.value).toBeTruthy(); + + items[1]!.value = 5; + expect(allEven.value).toBeFalsy(); + }); + + it('accepts a getter as the list source', () => { + const a = ref(1); + const b = ref(2); + const allPositive = useArrayEvery(() => [a.value, b.value], n => n > 0); + expect(allPositive.value).toBeTruthy(); + + b.value = -1; + expect(allPositive.value).toBeFalsy(); + }); + + it('passes index and array to the predicate', () => { + const list = ref([0, 1, 2, 3]); + const matchesIndex = useArrayEvery(list, (element, index, array) => { + expect(array).toBe(list.value); + return element === index; + }); + expect(matchesIndex.value).toBeTruthy(); + }); + + it('returns true for an empty array (vacuous truth)', () => { + const list = ref([]); + const result = useArrayEvery(list, n => n > 0); + expect(result.value).toBeTruthy(); + }); + + it('is SSR-safe: never touches window/document/navigator', () => { + const list = ref([1, 2, 3]); + // Pure computed wrapper — evaluating it relies on no browser globals. + const result = useArrayEvery(list, n => n > 0); + expect(result.value).toBeTruthy(); + }); +}); diff --git a/vue/toolkit/src/composables/array/useArrayEvery/index.ts b/vue/toolkit/src/composables/array/useArrayEvery/index.ts new file mode 100644 index 0000000..ec9056b --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayEvery/index.ts @@ -0,0 +1,30 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type UseArrayEveryReturn = ComputedRef; + +/** + * @name useArrayEvery + * @category Array + * @description Reactive `Array.prototype.every`. The source array and its items may be reactive. + * + * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) + * @param {(element: T, index: number, array: MaybeRefOrGetter[]) => unknown} fn Predicate to test each element + * @returns {UseArrayEveryReturn} A computed boolean that is `true` if `fn` returns a truthy value for every element, otherwise `false` + * + * @example + * const list = ref([1, 2, 3, 4]); + * const allPositive = useArrayEvery(list, n => n > 0); // true + * + * @example + * const items = [ref(2), ref(4), ref(6)]; + * const allEven = useArrayEvery(items, n => n % 2 === 0); // true + * + * @since 0.0.15 + */ +export function useArrayEvery( + list: MaybeRefOrGetter>>, + fn: (element: T, index: number, array: Array>) => unknown, +): UseArrayEveryReturn { + return computed(() => toValue(list).every((element, index, array) => fn(toValue(element), index, array))); +} diff --git a/vue/toolkit/src/composables/reactivity/useArrayFilter/index.test.ts b/vue/toolkit/src/composables/array/useArrayFilter/index.test.ts similarity index 100% rename from vue/toolkit/src/composables/reactivity/useArrayFilter/index.test.ts rename to vue/toolkit/src/composables/array/useArrayFilter/index.test.ts diff --git a/vue/toolkit/src/composables/reactivity/useArrayFilter/index.ts b/vue/toolkit/src/composables/array/useArrayFilter/index.ts similarity index 97% rename from vue/toolkit/src/composables/reactivity/useArrayFilter/index.ts rename to vue/toolkit/src/composables/array/useArrayFilter/index.ts index a987e65..2fb43d0 100644 --- a/vue/toolkit/src/composables/reactivity/useArrayFilter/index.ts +++ b/vue/toolkit/src/composables/array/useArrayFilter/index.ts @@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue'; /** * @name useArrayFilter - * @category Reactivity + * @category Array * @description Reactive `Array.prototype.filter`. * * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) diff --git a/vue/toolkit/src/composables/reactivity/useArrayFind/index.test.ts b/vue/toolkit/src/composables/array/useArrayFind/index.test.ts similarity index 100% rename from vue/toolkit/src/composables/reactivity/useArrayFind/index.test.ts rename to vue/toolkit/src/composables/array/useArrayFind/index.test.ts diff --git a/vue/toolkit/src/composables/reactivity/useArrayFind/index.ts b/vue/toolkit/src/composables/array/useArrayFind/index.ts similarity index 97% rename from vue/toolkit/src/composables/reactivity/useArrayFind/index.ts rename to vue/toolkit/src/composables/array/useArrayFind/index.ts index 563966e..57a920e 100644 --- a/vue/toolkit/src/composables/reactivity/useArrayFind/index.ts +++ b/vue/toolkit/src/composables/array/useArrayFind/index.ts @@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue'; /** * @name useArrayFind - * @category Reactivity + * @category Array * @description Reactive `Array.prototype.find`. * * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) diff --git a/vue/toolkit/src/composables/array/useArrayFindIndex/index.test.ts b/vue/toolkit/src/composables/array/useArrayFindIndex/index.test.ts new file mode 100644 index 0000000..573fd07 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayFindIndex/index.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArrayFindIndex } from '.'; + +describe(useArrayFindIndex, () => { + it('finds the index reactively', () => { + const list = ref([1, 2, 3]); + const index = useArrayFindIndex(list, n => n > 1); + expect(index.value).toBe(1); + + list.value = [10, 20]; + expect(index.value).toBe(0); + }); + + it('returns -1 when nothing matches', () => { + const index = useArrayFindIndex(ref([1, 2]), n => n > 5); + expect(index.value).toBe(-1); + }); + + it('returns -1 for an empty array', () => { + const index = useArrayFindIndex(ref([]), () => true); + expect(index.value).toBe(-1); + }); + + it('passes element, index and the resolved array to the predicate', () => { + const calls: Array<[number, number, number[]]> = []; + const index = useArrayFindIndex(ref([5, 6, 7]), (element, idx, array) => { + calls.push([element, idx, array]); + return element === 7; + }); + + expect(index.value).toBe(2); + expect(calls).toEqual([ + [5, 0, [5, 6, 7]], + [6, 1, [5, 6, 7]], + [7, 2, [5, 6, 7]], + ]); + }); + + it('unwraps reactive items inside the list', () => { + const a = ref(1); + const b = ref(2); + const index = useArrayFindIndex([a, b], n => n === 2); + expect(index.value).toBe(1); + + b.value = 0; + a.value = 0; + expect(index.value).toBe(-1); + }); + + it('accepts a getter as the source list', () => { + const source = ref([3, 4, 5]); + const index = useArrayFindIndex(() => source.value, n => n % 2 === 0); + expect(index.value).toBe(1); + + source.value = [1, 3, 5]; + expect(index.value).toBe(-1); + }); + + it('accepts a plain (non-reactive) array', () => { + const index = useArrayFindIndex([10, 20, 30], n => n === 30); + expect(index.value).toBe(2); + }); + + it('returns the FIRST matching index', () => { + const index = useArrayFindIndex(ref([2, 4, 6, 8]), n => n % 2 === 0); + expect(index.value).toBe(0); + }); +}); diff --git a/vue/toolkit/src/composables/array/useArrayFindIndex/index.ts b/vue/toolkit/src/composables/array/useArrayFindIndex/index.ts new file mode 100644 index 0000000..013da51 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayFindIndex/index.ts @@ -0,0 +1,29 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type UseArrayFindIndexReturn = ComputedRef; + +/** + * @name useArrayFindIndex + * @category Array + * @description Reactive `Array.prototype.findIndex`. + * + * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) + * @param {(element: T, index: number, array: T[]) => unknown} fn Predicate testing each element + * @returns {UseArrayFindIndexReturn} The index of the first matching element, or `-1` if none match + * + * @example + * const list = ref([1, 2, 3]); + * const index = useArrayFindIndex(list, n => n > 1); // 1 + * + * @since 0.0.15 + */ +export function useArrayFindIndex( + list: MaybeRefOrGetter>>, + fn: (element: T, index: number, array: T[]) => unknown, +): UseArrayFindIndexReturn { + return computed(() => { + const resolved = toValue(list).map(item => toValue(item)); + return resolved.findIndex(fn); + }); +} diff --git a/vue/toolkit/src/composables/array/useArrayFindLast/index.test.ts b/vue/toolkit/src/composables/array/useArrayFindLast/index.test.ts new file mode 100644 index 0000000..f72746d --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayFindLast/index.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; +import { useArrayFindLast } from '.'; + +describe(useArrayFindLast, () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('finds the last matching element reactively', () => { + const list = ref([1, 2, 3, 4]); + const found = useArrayFindLast(list, n => n % 2 === 0); + expect(found.value).toBe(4); + + list.value = [10, 20, 21]; + expect(found.value).toBe(20); + }); + + it('returns undefined when nothing matches', () => { + const found = useArrayFindLast(ref([1, 2]), n => n > 5); + expect(found.value).toBeUndefined(); + }); + + it('returns undefined for an empty array', () => { + const found = useArrayFindLast(ref([]), () => true); + expect(found.value).toBeUndefined(); + }); + + it('returns the LAST matching element', () => { + const found = useArrayFindLast(ref([2, 4, 6, 8]), n => n % 2 === 0); + expect(found.value).toBe(8); + }); + + it('passes element, index and the resolved array to the predicate', () => { + const calls: Array<[number, number, number[]]> = []; + const found = useArrayFindLast(ref([5, 6, 7]), (element, idx, array) => { + calls.push([element, idx, array]); + return element === 7; + }); + + expect(found.value).toBe(7); + // findLast iterates from the end; the match at index 2 stops it immediately. + expect(calls).toEqual([ + [7, 2, [5, 6, 7]], + ]); + }); + + it('unwraps reactive items inside the list', () => { + const a = ref(1); + const b = ref(2); + const found = useArrayFindLast([a, b], n => n < 5); + expect(found.value).toBe(2); + + b.value = 9; + expect(found.value).toBe(1); + }); + + it('accepts a getter as the source list', () => { + const source = ref([3, 4, 5, 6]); + const found = useArrayFindLast(() => source.value, n => n % 2 === 0); + expect(found.value).toBe(6); + + source.value = [1, 3, 5]; + expect(found.value).toBeUndefined(); + }); + + it('accepts a plain (non-reactive) array', () => { + const found = useArrayFindLast([10, 20, 30], n => n < 25); + expect(found.value).toBe(20); + }); + + it('works via the polyfill when Array.prototype.findLast is unavailable', () => { + const native = Array.prototype.findLast; + try { + // Simulate a runtime older than ES2023. + (Array.prototype as { findLast?: unknown }).findLast = undefined; + vi.resetModules(); + // The presence check runs at module import time, so re-import here. + return import('.').then(({ useArrayFindLast: useArrayFindLastFresh }) => { + const found = useArrayFindLastFresh(ref([1, 2, 3, 4]), n => n % 2 === 0); + expect(found.value).toBe(4); + + const none = useArrayFindLastFresh(ref([1, 3, 5]), n => n % 2 === 0); + expect(none.value).toBeUndefined(); + }); + } + finally { + (Array.prototype as { findLast?: unknown }).findLast = native; + } + }); +}); diff --git a/vue/toolkit/src/composables/array/useArrayFindLast/index.ts b/vue/toolkit/src/composables/array/useArrayFindLast/index.ts new file mode 100644 index 0000000..5981b49 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayFindLast/index.ts @@ -0,0 +1,48 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type UseArrayFindLastReturn + = ComputedRef; + +/** + * `Array.prototype.findLast` polyfill for runtimes older than ES2023. + */ +function findLast( + array: T[], + fn: (element: T, index: number, array: T[]) => unknown, +): T | undefined { + let index = array.length; + while (index-- > 0) { + const element = array[index]!; + if (fn(element, index, array)) + return element; + } + return undefined; +} + +const hasNativeFindLast = typeof Array.prototype.findLast === 'function'; + +/** + * @name useArrayFindLast + * @category Array + * @description Reactive `Array.prototype.findLast`. + * + * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) + * @param {(element: T, index: number, array: T[]) => unknown} fn Predicate testing each element + * @returns {UseArrayFindLastReturn} The last matching element, or `undefined` if none match + * + * @example + * const list = ref([1, 2, 3, 4]); + * const found = useArrayFindLast(list, n => n % 2 === 0); // 4 + * + * @since 0.0.15 + */ +export function useArrayFindLast( + list: MaybeRefOrGetter>>, + fn: (element: T, index: number, array: T[]) => unknown, +): UseArrayFindLastReturn { + return computed(() => { + const resolved = toValue(list).map(item => toValue(item)); + return hasNativeFindLast ? resolved.findLast(fn) : findLast(resolved, fn); + }); +} diff --git a/vue/toolkit/src/composables/array/useArrayIncludes/index.test.ts b/vue/toolkit/src/composables/array/useArrayIncludes/index.test.ts new file mode 100644 index 0000000..33226c6 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayIncludes/index.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArrayIncludes } from '.'; + +describe(useArrayIncludes, () => { + it('returns true when the value is present', () => { + const list = ref([1, 2, 3, 4]); + const has = useArrayIncludes(list, 3); + expect(has.value).toBeTruthy(); + }); + + it('returns false when the value is absent', () => { + const list = ref([1, 2, 3, 4]); + const has = useArrayIncludes(list, 5); + expect(has.value).toBeFalsy(); + }); + + it('returns false for an empty array', () => { + const list = ref([]); + const has = useArrayIncludes(list, 1); + expect(has.value).toBeFalsy(); + }); + + it('updates reactively when the source array changes', () => { + const list = ref([1, 2, 3]); + const has = useArrayIncludes(list, 4); + expect(has.value).toBeFalsy(); + + list.value = [1, 4, 5]; + expect(has.value).toBeTruthy(); + + list.value = [1, 2]; + expect(has.value).toBeFalsy(); + }); + + it('updates reactively when the searched value changes', () => { + const list = ref([1, 2, 3]); + const target = ref(2); + const has = useArrayIncludes(list, target); + expect(has.value).toBeTruthy(); + + target.value = 9; + expect(has.value).toBeFalsy(); + }); + + it('unwraps reactive items', () => { + const list = [ref(1), ref(2), ref(3)]; + const has = useArrayIncludes(list, 2); + expect(has.value).toBeTruthy(); + }); + + it('reacts to changes in reactive items', () => { + const a = ref(1); + const b = ref(2); + const has = useArrayIncludes([a, b], 9); + expect(has.value).toBeFalsy(); + + b.value = 9; + expect(has.value).toBeTruthy(); + }); + + it('accepts a getter as the source', () => { + const source = ref([1, 2, 3]); + const has = useArrayIncludes(() => source.value, 3); + expect(has.value).toBeTruthy(); + + source.value = [1, 2]; + expect(has.value).toBeFalsy(); + }); + + it('supports a custom comparator function', () => { + const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]); + const has = useArrayIncludes(list, 2, (element, value) => element.id === value); + expect(has.value).toBeTruthy(); + + const missing = useArrayIncludes(list, 9, (element, value) => element.id === value); + expect(missing.value).toBeFalsy(); + }); + + it('passes index and array to the comparator', () => { + const list = ref(['a', 'b', 'c']); + const calls: Array<[string, string, number, number]> = []; + const has = useArrayIncludes(list, 'z', (element, value, index, array) => { + calls.push([element, value, index, array.length]); + return false; + }); + expect(has.value).toBeFalsy(); + expect(calls).toEqual([ + ['a', 'z', 0, 3], + ['b', 'z', 1, 3], + ['c', 'z', 2, 3], + ]); + }); + + it('supports a key of T as the comparator', () => { + const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]); + const has = useArrayIncludes(list, 2, 'id'); + expect(has.value).toBeTruthy(); + + const missing = useArrayIncludes(list, 9, 'id'); + expect(missing.value).toBeFalsy(); + }); + + it('reacts to changes when comparing by key', () => { + const list = ref([{ id: 1 }, { id: 2 }]); + const target = ref(2); + const has = useArrayIncludes(list, target, 'id'); + expect(has.value).toBeTruthy(); + + target.value = 5; + expect(has.value).toBeFalsy(); + + list.value = [{ id: 5 }]; + expect(has.value).toBeTruthy(); + }); + + it('honors a positive fromIndex', () => { + const list = ref(['a', 'b', 'a']); + const fromZero = useArrayIncludes(list, 'a', { fromIndex: 0 }); + expect(fromZero.value).toBeTruthy(); + + const fromTwo = useArrayIncludes(list, 'a', { fromIndex: 2 }); + expect(fromTwo.value).toBeTruthy(); + + const fromThree = useArrayIncludes(list, 'a', { fromIndex: 3 }); + expect(fromThree.value).toBeFalsy(); + }); + + it('honors a negative fromIndex like Array.includes', () => { + const list = ref([1, 2, 3, 4, 5]); + const lastTwo = useArrayIncludes(list, 3, { fromIndex: -2 }); + expect(lastTwo.value).toBeFalsy(); + + const lastThree = useArrayIncludes(list, 3, { fromIndex: -3 }); + expect(lastThree.value).toBeTruthy(); + + // Negative index beyond the start clamps to 0. + const wayBack = useArrayIncludes(list, 1, { fromIndex: -100 }); + expect(wayBack.value).toBeTruthy(); + }); + + it('combines comparator and fromIndex in the options object', () => { + const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]); + const has = useArrayIncludes(list, 1, { + comparator: 'id', + fromIndex: 1, + }); + expect(has.value).toBeTruthy(); + + const missing = useArrayIncludes(list, 2, { + comparator: 'id', + fromIndex: 2, + }); + expect(missing.value).toBeFalsy(); + }); + + it('uses strict equality by default', () => { + const list = ref>([1, 2, 3]); + const has = useArrayIncludes(list, '2'); + expect(has.value).toBeFalsy(); + }); + + it('matches the searched value when it is a reactive getter', () => { + const list = ref([10, 20, 30]); + const has = useArrayIncludes(list, () => 20); + expect(has.value).toBeTruthy(); + }); +}); diff --git a/vue/toolkit/src/composables/array/useArrayIncludes/index.ts b/vue/toolkit/src/composables/array/useArrayIncludes/index.ts new file mode 100644 index 0000000..1f7a407 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayIncludes/index.ts @@ -0,0 +1,115 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import { isObject, isString } from '@robonen/stdlib'; + +/** + * Comparator deciding whether an array element equals the searched value. + */ +export type UseArrayIncludesComparatorFn + = (element: T, value: V, index: number, array: T[]) => boolean; + +export interface UseArrayIncludesOptions { + /** + * Index at which to start searching (negative counts from the end, like `Array.prototype.includes`). + * + * @default 0 + */ + fromIndex?: number; + /** + * Custom comparator function, or a key of `T` to compare a single property by. + */ + comparator?: UseArrayIncludesComparatorFn | keyof T; +} + +export type UseArrayIncludesReturn = ComputedRef; + +function isArrayIncludesOptions(value: unknown): value is UseArrayIncludesOptions { + // isObject matches PLAIN objects only, so functions/keys never reach here. + return isObject(value) && ('fromIndex' in value || 'comparator' in value); +} + +/** + * @name useArrayIncludes + * @category Array + * @description Reactive `Array.prototype.includes` with an optional comparator and `fromIndex`. The source array and its items may be reactive. + * + * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) + * @param {MaybeRefOrGetter} value The value to search for (may be reactive) + * @param {UseArrayIncludesComparatorFn | keyof T | UseArrayIncludesOptions} [comparator] A comparator function, a key of `T` to compare by, or an options object with `comparator`/`fromIndex` + * @returns {UseArrayIncludesReturn} A computed boolean that is `true` when the value is found + * + * @example + * const list = ref([1, 2, 3, 4]); + * const hasThree = useArrayIncludes(list, 3); // true + * + * @example + * const list = ref([{ id: 1 }, { id: 2 }]); + * const hasTwo = useArrayIncludes(list, 2, 'id'); // compare by key + * + * @example + * const list = ref(['a', 'b', 'a']); + * const fromSecond = useArrayIncludes(list, 'a', { fromIndex: 1 }); // true + * + * @since 0.0.15 + */ +export function useArrayIncludes( + list: MaybeRefOrGetter>>, + value: MaybeRefOrGetter, + comparator?: UseArrayIncludesComparatorFn, +): UseArrayIncludesReturn; +export function useArrayIncludes( + list: MaybeRefOrGetter>>, + value: MaybeRefOrGetter, + comparator?: keyof T, +): UseArrayIncludesReturn; +export function useArrayIncludes( + list: MaybeRefOrGetter>>, + value: MaybeRefOrGetter, + options?: UseArrayIncludesOptions, +): UseArrayIncludesReturn; +export function useArrayIncludes( + list: MaybeRefOrGetter>>, + value: MaybeRefOrGetter, + comparator?: UseArrayIncludesComparatorFn | keyof T | UseArrayIncludesOptions, +): UseArrayIncludesReturn { + let fromIndex = 0; + let resolved = comparator; + + if (isArrayIncludesOptions(resolved)) { + fromIndex = resolved.fromIndex ?? 0; + resolved = resolved.comparator; + } + + // Resolve the comparator once instead of on every recompute. + let compare: UseArrayIncludesComparatorFn; + + if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') { + const key = resolved as keyof T; + compare = (element, searched) => element[key] === (searched as unknown); + } + else if (typeof resolved === 'function') { + compare = resolved; + } + else { + compare = (element, searched) => (element as unknown) === searched; + } + + return computed(() => { + const array = toValue(list); + const searched = toValue(value); + const length = array.length; + + // Resolve a negative / out-of-range fromIndex the same way Array.includes does. + let start = fromIndex < 0 ? length + fromIndex : fromIndex; + if (start < 0) + start = 0; + + for (let index = start; index < length; index++) { + // `index` is bounded by `length`; `!` drops the index-access undefined. + if (compare(toValue(array[index]!), searched, index, array as T[])) + return true; + } + + return false; + }); +} diff --git a/vue/toolkit/src/composables/array/useArrayJoin/index.test.ts b/vue/toolkit/src/composables/array/useArrayJoin/index.test.ts new file mode 100644 index 0000000..3ff87bd --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayJoin/index.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArrayJoin } from '.'; + +describe(useArrayJoin, () => { + it('joins with the default comma separator', () => { + const list = ref(['a', 'b', 'c']); + const joined = useArrayJoin(list); + expect(joined.value).toBe('a,b,c'); + }); + + it('joins with a static separator', () => { + const list = ref(['a', 'b', 'c']); + const joined = useArrayJoin(list, '-'); + expect(joined.value).toBe('a-b-c'); + }); + + it('recomputes when the source array changes', () => { + const list = ref(['a', 'b']); + const joined = useArrayJoin(list, '/'); + expect(joined.value).toBe('a/b'); + + list.value = ['x', 'y', 'z']; + expect(joined.value).toBe('x/y/z'); + }); + + it('reacts to a reactive separator', () => { + const list = ref(['a', 'b', 'c']); + const sep = ref('-'); + const joined = useArrayJoin(list, sep); + expect(joined.value).toBe('a-b-c'); + + sep.value = ' | '; + expect(joined.value).toBe('a | b | c'); + }); + + it('unwraps reactive items', () => { + const list = [ref('a'), ref('b'), ref('c')]; + const joined = useArrayJoin(list, '-'); + expect(joined.value).toBe('a-b-c'); + }); + + it('reacts to changes in reactive items', () => { + const a = ref('a'); + const list = [a, ref('b')]; + const joined = useArrayJoin(list, '-'); + expect(joined.value).toBe('a-b'); + + a.value = 'z'; + expect(joined.value).toBe('z-b'); + }); + + it('accepts a getter as the source list', () => { + const a = ref('a'); + const b = ref('b'); + const joined = useArrayJoin(() => [a.value, b.value], '-'); + expect(joined.value).toBe('a-b'); + + a.value = 'z'; + expect(joined.value).toBe('z-b'); + }); + + it('returns an empty string for an empty array', () => { + const list = ref([]); + const joined = useArrayJoin(list, '-'); + expect(joined.value).toBe(''); + }); + + it('returns the single element with no separator applied', () => { + const list = ref(['only']); + const joined = useArrayJoin(list, '-'); + expect(joined.value).toBe('only'); + }); + + it('stringifies non-string elements like native join', () => { + const list = ref([1, 2, 3]); + const joined = useArrayJoin(list, '+'); + expect(joined.value).toBe('1+2+3'); + }); + + it('renders null and undefined as empty strings like native join', () => { + const list = ref([null, 'a', undefined, 'b']); + const joined = useArrayJoin(list, ','); + expect(joined.value).toBe(',a,,b'); + }); + + it('treats an empty-string separator as concatenation', () => { + const list = ref(['a', 'b', 'c']); + const joined = useArrayJoin(list, ''); + expect(joined.value).toBe('abc'); + }); + + it('joins a getter list of reactive items', () => { + const a = ref('a'); + const b = ref('b'); + const list = ref([a, b]); + const joined = useArrayJoin(() => list.value, '-'); + expect(joined.value).toBe('a-b'); + + b.value = 'z'; + expect(joined.value).toBe('a-z'); + }); +}); diff --git a/vue/toolkit/src/composables/array/useArrayJoin/index.ts b/vue/toolkit/src/composables/array/useArrayJoin/index.ts new file mode 100644 index 0000000..c156e43 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayJoin/index.ts @@ -0,0 +1,42 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type UseArrayJoinReturn = ComputedRef; + +/** + * @name useArrayJoin + * @category Array + * @description Reactive `Array.prototype.join`, with an optional reactive separator. + * + * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) + * @param {MaybeRefOrGetter} [separator] A reactive separator placed between adjacent elements (defaults to `,`) + * @returns {UseArrayJoinReturn} A computed string of all elements joined; empty string when the array is empty + * + * @example + * const list = ref(['a', 'b', 'c']); + * const sep = ref('-'); + * const joined = useArrayJoin(list, sep); // 'a-b-c' + * + * @since 0.0.15 + */ +export function useArrayJoin( + list: MaybeRefOrGetter>>, + separator?: MaybeRefOrGetter, +): UseArrayJoinReturn { + return computed(() => { + const resolved = toValue(list); + + // `Array.prototype.join` already stringifies each element, but resolving + // reactive items first lets the computed track per-item ref dependencies. + let needsUnwrap = false; + for (const item of resolved) { + if (typeof item === 'function' || (typeof item === 'object' && item !== null && 'value' in item)) { + needsUnwrap = true; + break; + } + } + + const source = needsUnwrap ? resolved.map(item => toValue(item)) : resolved; + return source.join(toValue(separator)); + }); +} diff --git a/vue/toolkit/src/composables/reactivity/useArrayMap/index.test.ts b/vue/toolkit/src/composables/array/useArrayMap/index.test.ts similarity index 100% rename from vue/toolkit/src/composables/reactivity/useArrayMap/index.test.ts rename to vue/toolkit/src/composables/array/useArrayMap/index.test.ts diff --git a/vue/toolkit/src/composables/reactivity/useArrayMap/index.ts b/vue/toolkit/src/composables/array/useArrayMap/index.ts similarity index 96% rename from vue/toolkit/src/composables/reactivity/useArrayMap/index.ts rename to vue/toolkit/src/composables/array/useArrayMap/index.ts index 0c7c936..f8691f3 100644 --- a/vue/toolkit/src/composables/reactivity/useArrayMap/index.ts +++ b/vue/toolkit/src/composables/array/useArrayMap/index.ts @@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue'; /** * @name useArrayMap - * @category Reactivity + * @category Array * @description Reactive `Array.prototype.map`. * * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) diff --git a/vue/toolkit/src/composables/array/useArrayReduce/index.test.ts b/vue/toolkit/src/composables/array/useArrayReduce/index.test.ts new file mode 100644 index 0000000..fe24332 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayReduce/index.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArrayReduce } from '.'; + +describe(useArrayReduce, () => { + it('reduces without an initial value', () => { + const list = ref([1, 2, 3, 4]); + const sum = useArrayReduce(list, (acc, n) => acc + n); + expect(sum.value).toBe(10); + }); + + it('reduces with an initial value', () => { + const list = ref([1, 2, 3, 4]); + const sum = useArrayReduce(list, (acc, n) => acc + n, 100); + expect(sum.value).toBe(110); + }); + + it('recomputes when the source array changes', () => { + const list = ref([1, 2, 3]); + const sum = useArrayReduce(list, (acc, n) => acc + n, 0); + expect(sum.value).toBe(6); + + list.value = [10, 20]; + expect(sum.value).toBe(30); + }); + + it('unwraps reactive items', () => { + const list = [ref(1), ref(2), ref(3)]; + const sum = useArrayReduce(list, (acc, n) => acc + n, 0); + expect(sum.value).toBe(6); + }); + + it('reacts to a reactive initial value', () => { + const list = ref([1, 2, 3]); + const seed = ref(10); + const sum = useArrayReduce(list, (acc, n) => acc + n, seed); + expect(sum.value).toBe(16); + + seed.value = 100; + expect(sum.value).toBe(106); + }); + + it('passes the current index to the reducer', () => { + const list = ref(['a', 'b', 'c']); + const indexed = useArrayReduce( + list, + (acc, value, index) => `${acc}${index}:${value};`, + '', + ); + expect(indexed.value).toBe('0:a;1:b;2:c;'); + }); + + it('supports a different accumulator type via initial value', () => { + const list = ref(['a', 'b', 'a', 'c', 'b']); + const counts = useArrayReduce( + list, + (acc: Record, key) => { + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, + () => ({}) as Record, + ); + expect(counts.value).toEqual({ a: 2, b: 2, c: 1 }); + }); + + it('treats undefined as a valid initial value (not a missing seed)', () => { + const list = ref([1, 2]); + // With a real seed of `undefined`, the reducer runs for every element. + const calls: Array<[unknown, number]> = []; + const result = useArrayReduce( + list, + (acc, n) => { + calls.push([acc, n]); + return n; + }, + undefined, + ); + expect(result.value).toBe(2); + expect(calls).toEqual([[undefined, 1], [1, 2]]); + }); + + it('throws on an empty array with no initial value (native reduce semantics)', () => { + const list = ref([]); + const sum = useArrayReduce(list, (acc, n) => acc + n); + expect(() => sum.value).toThrow(TypeError); + }); + + it('returns the initial value for an empty array', () => { + const list = ref([]); + const sum = useArrayReduce(list, (acc, n) => acc + n, 42); + expect(sum.value).toBe(42); + }); + + it('accepts a getter as the source list', () => { + const a = ref(1); + const b = ref(2); + const product = useArrayReduce(() => [a.value, b.value], (acc, n) => acc * n, 1); + expect(product.value).toBe(2); + + a.value = 5; + expect(product.value).toBe(10); + }); +}); diff --git a/vue/toolkit/src/composables/array/useArrayReduce/index.ts b/vue/toolkit/src/composables/array/useArrayReduce/index.ts new file mode 100644 index 0000000..645a688 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayReduce/index.ts @@ -0,0 +1,73 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type UseArrayReducer + = (accumulator: PV, currentValue: CV, currentIndex: number) => R; + +export type UseArrayReduceReturn = ComputedRef; + +/** + * @name useArrayReduce + * @category Array + * @description Reactive `Array.prototype.reduce`, with an optional initial value. + * + * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) + * @param {UseArrayReducer} reducer A reducer callback applied to each element + * @returns {UseArrayReduceReturn} The reduced value + * + * @example + * const list = ref([1, 2, 3, 4]); + * const sum = useArrayReduce(list, (acc, n) => acc + n); // 10 + * + * @since 0.0.15 + */ +export function useArrayReduce( + list: MaybeRefOrGetter>>, + reducer: UseArrayReducer, +): UseArrayReduceReturn; + +/** + * @name useArrayReduce + * @category Array + * @description Reactive `Array.prototype.reduce`, with an optional initial value. + * + * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) + * @param {UseArrayReducer} reducer A reducer callback applied to each element + * @param {MaybeRefOrGetter} initialValue A reactive value to seed the accumulator with + * @returns {UseArrayReduceReturn} The reduced value + * + * @example + * const list = ref([1, 2, 3, 4]); + * const sum = useArrayReduce(list, (acc, n) => acc + n, 100); // 110 + * + * @since 0.0.15 + */ +export function useArrayReduce( + list: MaybeRefOrGetter>>, + reducer: UseArrayReducer, + initialValue: MaybeRefOrGetter, +): UseArrayReduceReturn; + +export function useArrayReduce( + list: MaybeRefOrGetter>>, + reducer: UseArrayReducer, + initialValue?: MaybeRefOrGetter, +): UseArrayReduceReturn { + const step = ( + accumulator: U, + current: MaybeRefOrGetter, + index: number, + ): U => reducer(accumulator, toValue(current), index); + + // Capture presence here (arguments.length, not a default value) so that an + // explicitly-passed `undefined` is still honoured as a real initial value. + const hasInitial = arguments.length >= 3; + + return computed(() => { + const resolved = toValue(list); + + return hasInitial + ? resolved.reduce(step, toValue(initialValue as MaybeRefOrGetter)) + : (resolved as unknown as U[]).reduce(step as unknown as (a: U, c: U, i: number) => U); + }); +} diff --git a/vue/toolkit/src/composables/array/useArraySome/index.test.ts b/vue/toolkit/src/composables/array/useArraySome/index.test.ts new file mode 100644 index 0000000..e75a19e --- /dev/null +++ b/vue/toolkit/src/composables/array/useArraySome/index.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useArraySome } from '.'; + +describe(useArraySome, () => { + it('returns true when any element matches', () => { + const list = ref([1, 2, 3, 4]); + const hasEven = useArraySome(list, n => n % 2 === 0); + expect(hasEven.value).toBeTruthy(); + }); + + it('returns false when no element matches', () => { + const list = ref([1, 3, 5, 7]); + const hasEven = useArraySome(list, n => n % 2 === 0); + expect(hasEven.value).toBeFalsy(); + }); + + it('returns false for an empty array', () => { + const list = ref([]); + const result = useArraySome(list, () => true); + expect(result.value).toBeFalsy(); + }); + + it('updates reactively when the source array changes', () => { + const list = ref([1, 3, 5]); + const hasEven = useArraySome(list, n => n % 2 === 0); + expect(hasEven.value).toBeFalsy(); + + list.value = [1, 2, 5]; + expect(hasEven.value).toBeTruthy(); + + list.value = [7, 9]; + expect(hasEven.value).toBeFalsy(); + }); + + it('unwraps reactive items', () => { + const list = [ref(1), ref(3), ref(4)]; + const hasEven = useArraySome(list, n => n % 2 === 0); + expect(hasEven.value).toBeTruthy(); + }); + + it('reacts to changes in reactive items', () => { + const a = ref(1); + const b = ref(3); + const list = [a, b]; + const hasEven = useArraySome(list, n => n % 2 === 0); + expect(hasEven.value).toBeFalsy(); + + b.value = 4; + expect(hasEven.value).toBeTruthy(); + }); + + it('accepts a getter as the source', () => { + const source = ref([1, 2, 3]); + const hasThree = useArraySome(() => source.value, n => n === 3); + expect(hasThree.value).toBeTruthy(); + + source.value = [1, 2]; + expect(hasThree.value).toBeFalsy(); + }); + + it('passes index and array to the predicate', () => { + const list = ref(['a', 'b', 'c']); + const calls: Array<[string, number, number]> = []; + const result = useArraySome(list, (element, index, array) => { + calls.push([element, index, array.length]); + return false; + }); + expect(result.value).toBeFalsy(); + expect(calls).toEqual([['a', 0, 3], ['b', 1, 3], ['c', 2, 3]]); + }); + + it('short-circuits on the first truthy result', () => { + const list = ref([1, 2, 3, 4]); + let visited = 0; + const result = useArraySome(list, (n) => { + visited++; + return n === 2; + }); + expect(result.value).toBeTruthy(); + expect(visited).toBe(2); + }); +}); diff --git a/vue/toolkit/src/composables/array/useArraySome/index.ts b/vue/toolkit/src/composables/array/useArraySome/index.ts new file mode 100644 index 0000000..c488b24 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArraySome/index.ts @@ -0,0 +1,30 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; + +export type UseArraySomeReturn = ComputedRef; + +/** + * @name useArraySome + * @category Array + * @description Reactive `Array.prototype.some`. The source array and its items may be reactive. + * + * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) + * @param {(element: T, index: number, array: MaybeRefOrGetter[]) => unknown} fn Predicate to test each element + * @returns {UseArraySomeReturn} A computed boolean that is `true` if `fn` returns a truthy value for any element, otherwise `false` + * + * @example + * const list = ref([1, 2, 3, 4]); + * const hasEven = useArraySome(list, n => n % 2 === 0); // true + * + * @example + * const items = [ref(1), ref(3), ref(5)]; + * const hasEven = useArraySome(items, n => n % 2 === 0); // false + * + * @since 0.0.15 + */ +export function useArraySome( + list: MaybeRefOrGetter>>, + fn: (element: T, index: number, array: Array>) => unknown, +): UseArraySomeReturn { + return computed(() => toValue(list).some((element, index, array) => fn(toValue(element), index, array))); +} diff --git a/vue/toolkit/src/composables/array/useArrayUnique/index.test.ts b/vue/toolkit/src/composables/array/useArrayUnique/index.test.ts new file mode 100644 index 0000000..0134305 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayUnique/index.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { effectScope, ref } from 'vue'; +import { useArrayUnique } from '.'; + +describe(useArrayUnique, () => { + it('de-duplicates primitive values using strict identity', () => { + const list = ref([1, 2, 2, 3, 3, 3]); + const result = useArrayUnique(list); + expect(result.value).toEqual([1, 2, 3]); + }); + + it('preserves first-seen insertion order', () => { + const list = ref([3, 1, 3, 2, 1]); + const result = useArrayUnique(list); + expect(result.value).toEqual([3, 1, 2]); + }); + + it('distinguishes values of different types with === semantics', () => { + const list = ref>([1, '1', 1, '1']); + const result = useArrayUnique(list); + expect(result.value).toEqual([1, '1']); + }); + + it('treats NaN occurrences as a single unique value', () => { + const list = ref([Number.NaN, Number.NaN, 1]); + const result = useArrayUnique(list); + expect(result.value).toEqual([Number.NaN, 1]); + }); + + it('returns an empty array for an empty source', () => { + const list = ref([]); + const result = useArrayUnique(list); + expect(result.value).toEqual([]); + }); + + it('updates reactively when the source array changes', () => { + const list = ref([1, 1, 2]); + const result = useArrayUnique(list); + expect(result.value).toEqual([1, 2]); + + list.value = [3, 3, 3, 4]; + expect(result.value).toEqual([3, 4]); + }); + + it('accepts a getter as the source', () => { + const source = ref([1, 2, 2]); + const result = useArrayUnique(() => source.value); + expect(result.value).toEqual([1, 2]); + + source.value = [5, 5, 6]; + expect(result.value).toEqual([5, 6]); + }); + + it('unwraps reactive items', () => { + const list = [ref(1), ref(1), ref(2)]; + const result = useArrayUnique(list); + expect(result.value).toEqual([1, 2]); + }); + + it('reacts to changes in reactive items', () => { + const a = ref(1); + const b = ref(2); + const result = useArrayUnique([a, b]); + expect(result.value).toEqual([1, 2]); + + b.value = 1; + expect(result.value).toEqual([1]); + }); + + it('de-duplicates by a key of T', () => { + const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]); + const result = useArrayUnique(list, 'id'); + expect(result.value).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it('keeps the first occurrence when de-duplicating by key', () => { + const list = ref([ + { id: 1, label: 'a' }, + { id: 1, label: 'b' }, + { id: 2, label: 'c' }, + ]); + const result = useArrayUnique(list, 'id'); + expect(result.value).toEqual([ + { id: 1, label: 'a' }, + { id: 2, label: 'c' }, + ]); + }); + + it('de-duplicates by a key extractor function', () => { + const list = ref([ + { name: 'Ann' }, + { name: 'Bob' }, + { name: 'Ann' }, + ]); + const result = useArrayUnique(list, item => item.name); + expect(result.value).toEqual([{ name: 'Ann' }, { name: 'Bob' }]); + }); + + it('de-duplicates with a custom comparator function', () => { + const list = ref([1.1, 1.4, 2.2, 2.9, 3.0]); + const result = useArrayUnique(list, (a: number, b: number) => Math.floor(a) === Math.floor(b)); + expect(result.value).toEqual([1.1, 2.2, 3.0]); + }); + + it('passes the resolved array to the comparator', () => { + const list = ref([1, 2, 2]); + const seen: number[] = []; + const result = useArrayUnique(list, (a: number, b: number, array: number[]) => { + seen.push(array.length); + return a === b; + }); + expect(result.value).toEqual([1, 2]); + expect(seen.every(length => length === 3)).toBeTruthy(); + }); + + it('reacts to changes when comparing by key', () => { + const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]); + const result = useArrayUnique(list, 'id'); + expect(result.value).toEqual([{ id: 1 }, { id: 2 }]); + + list.value = [{ id: 3 }, { id: 3 }, { id: 4 }]; + expect(result.value).toEqual([{ id: 3 }, { id: 4 }]); + }); + + it('reacts to changes when using a comparator function', () => { + const list = ref([1.1, 1.9]); + const result = useArrayUnique(list, (a: number, b: number) => Math.floor(a) === Math.floor(b)); + expect(result.value).toEqual([1.1]); + + list.value = [1.1, 2.2, 2.9]; + expect(result.value).toEqual([1.1, 2.2]); + }); + + it('works outside of a component instance (SSR-safe, no global access)', () => { + // The composable must not touch window/document/navigator: running it inside + // a bare effectScope (no component, no DOM globals needed) must succeed. + const scope = effectScope(); + let result: ReturnType> | undefined; + + scope.run(() => { + result = useArrayUnique(ref([1, 1, 2, 3, 3])); + }); + + expect(result?.value).toEqual([1, 2, 3]); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/array/useArrayUnique/index.ts b/vue/toolkit/src/composables/array/useArrayUnique/index.ts new file mode 100644 index 0000000..25bf683 --- /dev/null +++ b/vue/toolkit/src/composables/array/useArrayUnique/index.ts @@ -0,0 +1,127 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import { isString, unique } from '@robonen/stdlib'; + +/** + * Equality comparator deciding whether two array elements are duplicates. + */ +export type UseArrayUniqueComparatorFn + = (a: T, b: T, array: T[]) => boolean; + +/** + * Extracts the comparison key for an element. Two elements that produce the + * same key (via `===`/`Set` identity) are considered duplicates. + */ +export type UseArrayUniqueKeyFn + = (element: T) => PropertyKey; + +export type UseArrayUniqueReturn = ComputedRef; + +/** + * @name useArrayUnique + * @category Array + * @description Reactive de-duplicated array. By default uses `Set` identity (`===`); an optional key of `T`, key extractor (both O(n)), or full comparator (O(n²)) customizes equality. The source array and its items may be reactive. First-seen insertion order is preserved. + * + * @param {MaybeRefOrGetter[]>} list The source array (items can be reactive) + * @param {UseArrayUniqueComparatorFn | UseArrayUniqueKeyFn | keyof T} [comparator] A custom equality comparator, a key extractor, or a key of `T` to de-duplicate by + * @returns {UseArrayUniqueReturn} A computed array containing only the first occurrence of each unique element + * + * @example + * const list = ref([1, 2, 2, 3, 3, 3]); + * const uniq = useArrayUnique(list); // [1, 2, 3] + * + * @example + * const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]); + * const byId = useArrayUnique(list, 'id'); // [{ id: 1 }, { id: 2 }] + * + * @example + * const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]); + * const byKey = useArrayUnique(list, item => item.id); // [{ id: 1 }, { id: 2 }] + * + * @example + * const list = ref([1.1, 1.4, 2.2]); + * const byFloor = useArrayUnique(list, (a, b) => Math.floor(a) === Math.floor(b)); // [1.1, 2.2] + * + * @since 0.0.15 + */ +export function useArrayUnique( + list: MaybeRefOrGetter>>, +): UseArrayUniqueReturn; +export function useArrayUnique( + list: MaybeRefOrGetter>>, + comparator: keyof T, +): UseArrayUniqueReturn; +export function useArrayUnique( + list: MaybeRefOrGetter>>, + comparator: UseArrayUniqueKeyFn, +): UseArrayUniqueReturn; +export function useArrayUnique( + list: MaybeRefOrGetter>>, + comparator: UseArrayUniqueComparatorFn, +): UseArrayUniqueReturn; +export function useArrayUnique( + list: MaybeRefOrGetter>>, + comparator?: UseArrayUniqueComparatorFn | UseArrayUniqueKeyFn | keyof T, +): UseArrayUniqueReturn { + // Resolve the comparison strategy once, not on every recompute. + + // Key of T (string | number | symbol) -> O(n) first-seen-wins key de-dup. + if (isString(comparator) || typeof comparator === 'symbol' || typeof comparator === 'number') { + const key = comparator as keyof T; + return computed(() => uniqueByKey(resolve(list), element => element[key] as PropertyKey)); + } + + if (typeof comparator === 'function') { + // A unary key extractor stays O(n); a binary comparator falls back to O(n²) + // pairwise comparison (unavoidable for arbitrary equality). Branch on arity. + if (comparator.length <= 1) { + const extractor = comparator as UseArrayUniqueKeyFn; + return computed(() => uniqueByKey(resolve(list), extractor)); + } + + const compare = comparator as UseArrayUniqueComparatorFn; + return computed(() => { + const array = resolve(list); + const result: T[] = []; + + for (const value of array) { + if (!result.some(kept => compare(value, kept, array))) + result.push(value); + } + + return result; + }); + } + + // Default: identity (`===`) de-dup via stdlib unique's Set fast path. + return computed(() => unique(resolve(list))); +} + +/** + * Resolves the (possibly reactive) list and each (possibly reactive) item. + */ +function resolve(list: MaybeRefOrGetter>>): T[] { + return toValue(list).map(element => toValue(element)); +} + +/** + * O(n) de-duplication that keeps the FIRST element seen per extracted key + * (matching VueUse's first-occurrence semantics). stdlib `unique` is + * last-write-wins per key, so we track seen keys in a Set here instead. + */ +function uniqueByKey(array: T[], extractor: UseArrayUniqueKeyFn): T[] { + const seen = new Set(); + const result: T[] = []; + + for (const element of array) { + const key = extractor(element); + + if (seen.has(key)) + continue; + + seen.add(key); + result.push(element); + } + + return result; +} diff --git a/vue/toolkit/src/composables/array/useSorted/index.test.ts b/vue/toolkit/src/composables/array/useSorted/index.test.ts new file mode 100644 index 0000000..74ae9eb --- /dev/null +++ b/vue/toolkit/src/composables/array/useSorted/index.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { isReactive, nextTick, reactive, ref } from 'vue'; +import type { Ref } from 'vue'; +import { useSorted } from '.'; + +describe(useSorted, () => { + it('returns a sorted copy with the default numeric compare', () => { + const source = ref([3, 1, 2]); + const sorted = useSorted(source); + expect(sorted.value).toEqual([1, 2, 3]); + }); + + it('does not mutate the source by default', () => { + const original = [3, 1, 2]; + const source = ref(original); + const sorted = useSorted(source); + expect(sorted.value).toEqual([1, 2, 3]); + expect(source.value).toEqual([3, 1, 2]); + expect(sorted.value).not.toBe(source.value); + }); + + it('reacts to source changes', () => { + const source = ref([3, 1, 2]); + const sorted = useSorted(source); + expect(sorted.value).toEqual([1, 2, 3]); + source.value = [9, 5, 7, 1]; + expect(sorted.value).toEqual([1, 5, 7, 9]); + }); + + it('supports a custom compare function as the second argument', () => { + const source = ref([{ age: 30 }, { age: 18 }, { age: 25 }]); + const sorted = useSorted(source, (a, b) => a.age - b.age); + expect(sorted.value.map(u => u.age)).toEqual([18, 25, 30]); + }); + + it('supports descending order via compare function', () => { + const source = ref([1, 2, 3]); + const sorted = useSorted(source, (a, b) => b - a); + expect(sorted.value).toEqual([3, 2, 1]); + }); + + it('accepts an options object as the second argument', () => { + const source = ref([3, 1, 2]); + const sorted = useSorted(source, { compareFn: (a, b) => b - a }); + expect(sorted.value).toEqual([3, 2, 1]); + }); + + it('accepts a compare function plus an options object', () => { + const calls: number[] = []; + const sortFn = (arr: T[], compareFn: (a: T, b: T) => number): T[] => { + calls.push(arr.length); + return [...arr].sort(compareFn); + }; + const source = ref([3, 1, 2]); + const sorted = useSorted(source, (a, b) => a - b, { sortFn }); + expect(sorted.value).toEqual([1, 2, 3]); + expect(calls.length).toBeGreaterThan(0); + }); + + it('is stable: equal elements keep their original relative order', () => { + const source = ref([ + { k: 1, id: 'a' }, + { k: 1, id: 'b' }, + { k: 0, id: 'c' }, + { k: 1, id: 'd' }, + ]); + const sorted = useSorted(source, (a, b) => a.k - b.k); + expect(sorted.value.map(x => x.id)).toEqual(['c', 'a', 'b', 'd']); + }); + + it('works with getter sources', () => { + const base = ref([5, 3, 4]); + const sorted = useSorted(() => base.value); + expect(sorted.value).toEqual([3, 4, 5]); + base.value = [2, 1]; + expect(sorted.value).toEqual([1, 2]); + }); + + it('works with a plain (non-reactive) array source', () => { + const sorted = useSorted([3, 1, 2]); + expect(sorted.value).toEqual([1, 2, 3]); + }); + + it('handles empty and single-element arrays', () => { + expect(useSorted(ref([])).value).toEqual([]); + expect(useSorted(ref([42])).value).toEqual([42]); + }); + + describe('dirty mode', () => { + it('sorts the source ref in place', async () => { + const source = ref([3, 1, 2]); + const result = useSorted(source, { dirty: true }); + await nextTick(); + expect(source.value).toEqual([1, 2, 3]); + expect(result).toBe(source); + }); + + it('re-sorts when the source changes', async () => { + const source = ref([3, 1, 2]); + useSorted(source, { dirty: true }); + await nextTick(); + expect(source.value).toEqual([1, 2, 3]); + source.value = [9, 4, 6]; + await nextTick(); + expect(source.value).toEqual([4, 6, 9]); + }); + + it('honors a custom compare function in dirty mode', async () => { + const source = ref([1, 2, 3]); + useSorted(source, (a, b) => b - a, { dirty: true }); + await nextTick(); + expect(source.value).toEqual([3, 2, 1]); + }); + + it('mutates a reactive array source in place via a getter', async () => { + const source = reactive([3, 1, 2]); + useSorted(() => source, { dirty: true }); + await nextTick(); + expect(isReactive(source)).toBeTruthy(); + expect([...source]).toEqual([1, 2, 3]); + }); + }); + + describe('writable result', () => { + it('writes back to the source ref when assigned (non-dirty)', () => { + const source = ref([3, 1, 2]); + const sorted = useSorted(source); + sorted.value = [10, 20]; + expect(source.value).toEqual([10, 20]); + }); + + it('silently ignores writes when the source is a getter', () => { + const base = ref([3, 1, 2]); + const sorted = useSorted(() => base.value) as unknown as Ref; + expect(() => { + sorted.value = [10, 20]; + }).not.toThrow(); + // getter source is unchanged + expect(base.value).toEqual([3, 1, 2]); + }); + }); + + describe('SSR safety', () => { + it('does not touch any DOM global and works without a document', () => { + // useSorted is pure reactive computation; it must run identically in SSR. + const sorted = useSorted(ref([3, 1, 2])); + expect(sorted.value).toEqual([1, 2, 3]); + }); + }); +}); diff --git a/vue/toolkit/src/composables/array/useSorted/index.ts b/vue/toolkit/src/composables/array/useSorted/index.ts new file mode 100644 index 0000000..59fb566 --- /dev/null +++ b/vue/toolkit/src/composables/array/useSorted/index.ts @@ -0,0 +1,151 @@ +import { computed, isRef, toValue, watchEffect } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'; +import { isFunction } from '@robonen/stdlib'; + +export type UseSortedCompareFn + = (a: T, b: T) => number; + +export type UseSortedFn + = (arr: T[], compareFn: UseSortedCompareFn) => T[]; + +export interface UseSortedOptions { + /** + * The sort algorithm to apply. Receives a copy of the array (or the source + * itself in `dirty` mode) and the resolved compare function. + * + * Defaults to a guaranteed-stable merge sort, so equal elements always keep + * their original relative order regardless of the JS engine. + */ + sortFn?: UseSortedFn; + /** + * The compare function used to order two elements, matching the signature of + * `Array.prototype.sort`. + * + * @default (a, b) => a - b + */ + compareFn?: UseSortedCompareFn; + /** + * Sort the source array in place instead of returning a sorted copy. + * + * When `true`, the returned ref is the source itself and its values are + * re-sorted whenever the source changes. + * + * @default false + */ + dirty?: boolean; +} + +const defaultCompare: UseSortedCompareFn = (a, b) => a - b; + +/** + * Guaranteed-stable merge sort. Equal elements keep their original order on + * every engine, unlike the historically engine-dependent `Array.prototype.sort`. + */ +function stableSort(array: T[], compareFn: UseSortedCompareFn): T[] { + const length = array.length; + if (length < 2) + return array; + + const middle = length >> 1; + const left = stableSort(array.slice(0, middle), compareFn); + const right = stableSort(array.slice(middle), compareFn); + + const result: T[] = Array.from({ length }); + let i = 0; + let l = 0; + let r = 0; + + while (l < left.length && r < right.length) { + // Bounds are guaranteed by the loop condition; `!` drops the index-access undefined. + // `<= 0` keeps left (earlier) element first -> stability. + if (compareFn(left[l]!, right[r]!) <= 0) + result[i++] = left[l++]!; + else + result[i++] = right[r++]!; + } + + while (l < left.length) + result[i++] = left[l++]!; + while (r < right.length) + result[i++] = right[r++]!; + + return result; +} + +const defaultSortFn: UseSortedFn = (source: T[], compareFn: UseSortedCompareFn): T[] => stableSort(source, compareFn); + +/** + * @name useSorted + * @category Array + * @description Reactive, stable sorted copy of an array. Mirrors `Array.prototype.sort` but never mutates the source by default and guarantees stable ordering. + * + * @param {MaybeRefOrGetter} source The source array (ref, getter, or plain array) + * @param {UseSortedCompareFn | UseSortedOptions} [compareFn] A compare function, or an options object + * @param {Omit, 'compareFn'>} [options] Extra options when the second argument is a compare function + * @returns {ComputedRef | Ref} A computed sorted copy (default), or the source ref when `dirty` is `true` + * + * @example + * const list = ref([3, 1, 2]); + * const sorted = useSorted(list); // [1, 2, 3] + * + * @example + * // custom compare function + * const users = ref([{ age: 30 }, { age: 18 }]); + * const byAge = useSorted(users, (a, b) => a.age - b.age); + * + * @example + * // sort the source in place + * const list = ref([3, 1, 2]); + * useSorted(list, { dirty: true }); + * // list.value is now [1, 2, 3] + * + * @since 0.0.15 + */ +export function useSorted(source: Ref, compareFn?: UseSortedCompareFn): Ref; +export function useSorted(source: MaybeRefOrGetter, compareFn?: UseSortedCompareFn): ComputedRef; +export function useSorted(source: Ref, options?: UseSortedOptions): Ref; +export function useSorted(source: MaybeRefOrGetter, options?: UseSortedOptions): ComputedRef; +export function useSorted(source: Ref, compareFn?: UseSortedCompareFn, options?: Omit, 'compareFn'>): Ref; +export function useSorted(source: MaybeRefOrGetter, compareFn?: UseSortedCompareFn, options?: Omit, 'compareFn'>): ComputedRef; +export function useSorted( + source: MaybeRefOrGetter, + compareFnOrOptions?: UseSortedCompareFn | UseSortedOptions, + maybeOptions?: Omit, 'compareFn'>, +): ComputedRef | Ref { + let compareFn: UseSortedCompareFn = defaultCompare as UseSortedCompareFn; + let options: UseSortedOptions = {}; + + if (isFunction(compareFnOrOptions)) { + compareFn = compareFnOrOptions; + options = maybeOptions ?? {}; + } + else if (compareFnOrOptions) { + options = compareFnOrOptions; + compareFn = options.compareFn ?? (defaultCompare as UseSortedCompareFn); + } + + const { + dirty = false, + sortFn = defaultSortFn, + } = options; + + if (!dirty) { + return computed({ + get: () => sortFn([...toValue(source)], compareFn), + set: (value) => { + if (isRef(source)) + (source as Ref).value = value; + }, + }); + } + + watchEffect(() => { + const result = sortFn(toValue(source), compareFn); + if (isRef(source)) + (source as Ref).value = result; + else + (toValue(source)).splice(0, toValue(source).length, ...result); + }); + + return source as Ref; +} diff --git a/vue/toolkit/src/composables/reactivity/broadcastedRef/index.test.ts b/vue/toolkit/src/composables/browser/broadcastedRef/index.test.ts similarity index 100% rename from vue/toolkit/src/composables/reactivity/broadcastedRef/index.test.ts rename to vue/toolkit/src/composables/browser/broadcastedRef/index.test.ts diff --git a/vue/toolkit/src/composables/reactivity/broadcastedRef/index.ts b/vue/toolkit/src/composables/browser/broadcastedRef/index.ts similarity index 98% rename from vue/toolkit/src/composables/reactivity/broadcastedRef/index.ts rename to vue/toolkit/src/composables/browser/broadcastedRef/index.ts index 47b0280..d4bc485 100644 --- a/vue/toolkit/src/composables/reactivity/broadcastedRef/index.ts +++ b/vue/toolkit/src/composables/browser/broadcastedRef/index.ts @@ -13,7 +13,7 @@ export interface BroadcastedRefOptions { /** * @name broadcastedRef - * @category Reactivity + * @category Browser * @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API * * @param {string} key The channel key to use for broadcasting diff --git a/vue/toolkit/src/composables/browser/index.ts b/vue/toolkit/src/composables/browser/index.ts index f0c1ca3..83395d7 100644 --- a/vue/toolkit/src/composables/browser/index.ts +++ b/vue/toolkit/src/composables/browser/index.ts @@ -1,58 +1,36 @@ -export * from './onKeyStroke'; -export * from './useActiveElement'; -export * from './useBodyScrollLock'; -export * from './useClickOutside'; +export * from './broadcastedRef'; +export * from './useBreakpoints'; export * from './useClipboard'; +export * from './useClipboardItems'; export * from './useCloseWatcher'; export * from './useColorMode'; -export * from './useDevicePixelRatio'; -export * from './useDocumentReadyState'; -export * from './useDocumentVisibility'; -export * from './useDropZone'; -export * from './useElementBounding'; -export * from './useElementHover'; -export * from './useElementSize'; -export * from './useElementVisibility'; -export * from './useEscapeKey'; +export * from './useCssVar'; +export * from './useDark'; +export * from './useDocumentPiP'; export * from './useEventListener'; export * from './useEyeDropper'; export * from './useFavicon'; export * from './useFileDialog'; -export * from './useFocus'; -export * from './useFocusGuard'; -export * from './useFocusWithin'; -export * from './useFps'; +export * from './useFileSystemAccess'; export * from './useFullscreen'; -export * from './useGeolocation'; -export * from './useIdle'; -export * from './useIntersectionObserver'; -export * from './useIntervalFn'; -export * from './useKeyModifier'; -export * from './useMagicKeys'; +export * from './useImage'; +export * from './useLocalFonts'; export * from './useMediaQuery'; -export * from './useMouse'; -export * from './useMousePressed'; -export * from './useMutationObserver'; -export * from './useNetwork'; export * from './useObjectUrl'; -export * from './useOnline'; -export * from './usePageLeave'; export * from './usePermission'; -export * from './usePointer'; export * from './usePreferredColorScheme'; +export * from './usePreferredContrast'; export * from './usePreferredDark'; -export * from './useRafFn'; -export * from './useResizeObserver'; -export * from './useScreenOrientation'; -export * from './useScroll'; -export * from './useScrollLock'; +export * from './usePreferredLanguages'; +export * from './usePreferredReducedMotion'; +export * from './usePreferredReducedTransparency'; +export * from './useScriptTag'; export * from './useShare'; -export * from './useSupported'; -export * from './useSwipe'; +export * from './useStyleTag'; export * from './useTabLeader'; -export * from './useTextSelection'; +export * from './useTextareaAutosize'; export * from './useTitle'; +export * from './useUrlSearchParams'; export * from './useVibrate'; -export * from './useWindowFocus'; -export * from './useWindowScroll'; -export * from './useWindowSize'; +export * from './useWakeLock'; +export * from './useWebNotification'; diff --git a/vue/toolkit/src/composables/browser/useBreakpoints/index.test.ts b/vue/toolkit/src/composables/browser/useBreakpoints/index.test.ts new file mode 100644 index 0000000..534bab7 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useBreakpoints/index.test.ts @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { + breakpointsAntDesign, + breakpointsBootstrapV5, + breakpointsTailwind, + breakpointsVuetifyV3, + useBreakpoints, +} from '.'; + +type Listener = (event: { matches: boolean }) => void; + +interface StubMql { + readonly matches: boolean; + media: string; + addEventListener: (type: string, cb: Listener) => void; + removeEventListener: (type: string, cb: Listener) => void; +} + +/** + * A `matchMedia` stub backed by a mutable viewport width. It parses + * `(min-width: Npx)` / `(max-width: Npx)` (optionally joined by `and`) so each + * query evaluates against the current `width`. Calling `setWidth` re-dispatches + * `change` to every live MediaQueryList, mimicking a real viewport resize. + */ +function stubViewport(initialWidth: number) { + let width = initialWidth; + const lists = new Set<{ media: string; matches: boolean; listeners: Set }>(); + + function toPx(value: string): number { + const n = Number.parseFloat(value); + return /(?:em|rem)$/i.test(value) ? n * 16 : n; + } + + function evaluate(media: string): boolean { + return media.split(' and ').every((part) => { + const min = part.match(/min-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/); + const max = part.match(/max-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/); + + if (min) return width >= toPx(min[1]!); + if (max) return width <= toPx(max[1]!); + + return false; + }); + } + + const matchMedia = vi.fn((media: string): StubMql => { + const entry = { media, matches: evaluate(media), listeners: new Set() }; + lists.add(entry); + + return { + get matches() { + return entry.matches; + }, + media, + addEventListener: (_: string, cb: Listener) => entry.listeners.add(cb), + removeEventListener: (_: string, cb: Listener) => entry.listeners.delete(cb), + }; + }); + + vi.stubGlobal('matchMedia', matchMedia); + + return { + matchMedia, + setWidth(next: number) { + width = next; + + for (const entry of lists) { + const matches = evaluate(entry.media); + + if (matches !== entry.matches) { + entry.matches = matches; + + for (const cb of entry.listeners) cb({ matches }); + } + } + }, + }; +} + +describe(useBreakpoints, () => { + beforeEach(() => { + vi.stubGlobal('matchMedia', undefined); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('exposes a reactive shortcut ref per breakpoint (min-width strategy)', async () => { + stubViewport(800); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }); + }); + await nextTick(); + + // 800px: >= sm (640), >= md (768), < lg (1024) + expect(bp!.sm.value).toBeTruthy(); + expect(bp!.md.value).toBeTruthy(); + expect(bp!.lg.value).toBeFalsy(); + scope.stop(); + }); + + it('uses max-width semantics for shortcuts under the max-width strategy', async () => { + stubViewport(800); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' }); + }); + await nextTick(); + + // 800px: <= lg (1024) only; not <= sm/md + expect(bp!.sm.value).toBeFalsy(); + expect(bp!.md.value).toBeFalsy(); + expect(bp!.lg.value).toBeTruthy(); + scope.stop(); + }); + + it('reacts to viewport changes', async () => { + const vp = stubViewport(500); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ sm: 640, lg: 1024 }); + }); + await nextTick(); + + expect(bp!.greaterOrEqual('sm').value).toBeFalsy(); + + vp.setWidth(700); + await nextTick(); + expect(bp!.greaterOrEqual('sm').value).toBeTruthy(); + expect(bp!.greaterOrEqual('lg').value).toBeFalsy(); + + vp.setWidth(1100); + await nextTick(); + expect(bp!.greaterOrEqual('lg').value).toBeTruthy(); + scope.stop(); + }); + + it('greater/smaller apply the strict (exclusive) delta', async () => { + // Exactly at the md breakpoint (768). + const vp = stubViewport(768); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ md: 768 }); + }); + await nextTick(); + + // At exactly 768: greaterOrEqual true, greater false, smallerOrEqual true, smaller false. + expect(bp!.greaterOrEqual('md').value).toBeTruthy(); + expect(bp!.greater('md').value).toBeFalsy(); + expect(bp!.smallerOrEqual('md').value).toBeTruthy(); + expect(bp!.smaller('md').value).toBeFalsy(); + + vp.setWidth(900); + await nextTick(); + expect(bp!.greater('md').value).toBeTruthy(); + expect(bp!.smaller('md').value).toBeFalsy(); + scope.stop(); + }); + + it('between is half-open [a, b)', async () => { + const vp = stubViewport(768); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }); + }); + await nextTick(); + + // 768 is in [sm, lg) and in [md, lg); the upper bound (md) is exclusive in [sm, md). + expect(bp!.between('sm', 'lg').value).toBeTruthy(); + expect(bp!.between('sm', 'md').value).toBeFalsy(); + expect(bp!.between('md', 'lg').value).toBeTruthy(); + scope.stop(); + }); + + it('current() returns active breakpoints ordered small to large; active() picks per strategy', async () => { + stubViewport(800); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ lg: 1024, sm: 640, md: 768 }); + }); + await nextTick(); + + // 800px: sm and md active, sorted ascending. + expect(bp!.current().value).toEqual(['sm', 'md']); + // min-width strategy → largest active breakpoint. + expect(bp!.active().value).toBe('md'); + scope.stop(); + }); + + it('active() picks the smallest active breakpoint under max-width strategy', async () => { + stubViewport(800); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' }); + }); + await nextTick(); + + expect(bp!.active().value).toBe('lg'); + scope.stop(); + }); + + it('synchronous is* helpers read the current match', async () => { + stubViewport(800); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }); + }); + await nextTick(); + + expect(bp!.isGreaterOrEqual('md')).toBeTruthy(); + expect(bp!.isGreater('md')).toBeTruthy(); + expect(bp!.isGreaterOrEqual('lg')).toBeFalsy(); + expect(bp!.isSmallerOrEqual('lg')).toBeTruthy(); + expect(bp!.isSmaller('sm')).toBeFalsy(); + expect(bp!.isInBetween('sm', 'lg')).toBeTruthy(); + expect(bp!.isInBetween('sm', 'md')).toBeFalsy(); + scope.stop(); + }); + + it('supports reactive breakpoint values', async () => { + const vp = stubViewport(700); + const threshold = ref(640); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ point: threshold }); + }); + await nextTick(); + + expect(bp!.greaterOrEqual('point').value).toBeTruthy(); + + threshold.value = 900; + await nextTick(); + expect(bp!.greaterOrEqual('point').value).toBeFalsy(); + + vp.setWidth(1000); + await nextTick(); + expect(bp!.greaterOrEqual('point').value).toBeTruthy(); + scope.stop(); + }); + + it('handles string unit breakpoint values', async () => { + stubViewport(800); // 800px, 48em = 768px + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ md: '48em' }); + }); + await nextTick(); + + expect(bp!.greaterOrEqual('md').value).toBeTruthy(); + expect(bp!.isGreaterOrEqual('md')).toBeTruthy(); + scope.stop(); + }); + + describe('SSR / unsupported path', () => { + it('resolves width queries from ssrWidth when matchMedia is unavailable', () => { + // matchMedia stays undefined (beforeEach), window has no matchMedia path used by match(). + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ sm: 640, lg: 1024 }, { ssrWidth: 800 }); + }); + + // Synchronous helpers resolve against ssrWidth. + expect(bp!.isGreaterOrEqual('sm')).toBeTruthy(); + expect(bp!.isGreaterOrEqual('lg')).toBeFalsy(); + expect(bp!.isSmallerOrEqual('lg')).toBeTruthy(); + scope.stop(); + }); + + it('returns false for snapshot helpers with no window and no ssrWidth', () => { + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints({ sm: 640 }, { window: undefined }); + }); + + expect(bp!.isGreaterOrEqual('sm')).toBeFalsy(); + expect(bp!.isSmallerOrEqual('sm')).toBeFalsy(); + scope.stop(); + }); + + it('does not throw when constructed without a window (SSR)', () => { + const scope = effectScope(); + + expect(() => { + scope.run(() => { + const bp = useBreakpoints(breakpointsTailwind, { window: undefined }); + // Accessing reactive refs should be safe and default to false. + expect(bp.lg.value).toBeFalsy(); + }); + }).not.toThrow(); + scope.stop(); + }); + }); + + describe('presets', () => { + it('exports the expected preset values', () => { + expect(breakpointsTailwind).toMatchObject({ sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 }); + expect(breakpointsBootstrapV5).toMatchObject({ xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400 }); + expect(breakpointsAntDesign).toMatchObject({ xs: 480, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1600 }); + expect(breakpointsVuetifyV3).toMatchObject({ xs: 0, sm: 600, md: 960, lg: 1280, xl: 1920, xxl: 2560 }); + }); + + it('works with a preset', async () => { + stubViewport(1300); + const scope = effectScope(); + let bp: ReturnType>; + + scope.run(() => { + bp = useBreakpoints(breakpointsTailwind); + }); + await nextTick(); + + expect(bp!.greaterOrEqual('xl').value).toBeTruthy(); + expect(bp!.greaterOrEqual('2xl').value).toBeFalsy(); + expect(bp!.active().value).toBe('xl'); + scope.stop(); + }); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useBreakpoints/index.ts b/vue/toolkit/src/composables/browser/useBreakpoints/index.ts new file mode 100644 index 0000000..5a57602 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useBreakpoints/index.ts @@ -0,0 +1,266 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import { isFunction, isNumber } from '@robonen/stdlib'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useMediaQuery } from '@/composables/browser/useMediaQuery'; +import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery'; + +/** + * A breakpoints map: name → viewport width. Numbers are treated as pixels; + * strings keep their unit (`"48em"`, `"30rem"`, `"1024px"`). Values may be + * reactive (refs or getters). + */ +export type Breakpoints + = Record>; + +/** + * Which edge a generated shortcut property (e.g. `breakpoints.lg`) reacts to. + * + * - `'min-width'` (mobile-first) — `lg` is `true` when the viewport is at least + * the `lg` width. + * - `'max-width'` (desktop-first) — `lg` is `true` when the viewport is at most + * the `lg` width. + */ +export type UseBreakpointsStrategy = 'min-width' | 'max-width'; + +export interface UseBreakpointsOptions extends ConfigurableWindow, Pick { + /** + * The query strategy used by the generated shortcut properties. + * + * @default 'min-width' + */ + strategy?: UseBreakpointsStrategy; +} + +export type UseBreakpointsReturn + = Record> & { + /** Reactive: viewport width is greater than or equal to breakpoint `k` (`min-width`). */ + greaterOrEqual: (k: MaybeRefOrGetter) => ComputedRef; + /** Reactive: viewport width is smaller than or equal to breakpoint `k` (`max-width`). */ + smallerOrEqual: (k: MaybeRefOrGetter) => ComputedRef; + /** Reactive: viewport width is strictly greater than breakpoint `k`. */ + greater: (k: MaybeRefOrGetter) => ComputedRef; + /** Reactive: viewport width is strictly smaller than breakpoint `k`. */ + smaller: (k: MaybeRefOrGetter) => ComputedRef; + /** Reactive: viewport width is within `[a, b)`. */ + between: (a: MaybeRefOrGetter, b: MaybeRefOrGetter) => ComputedRef; + /** Snapshot: viewport width is strictly greater than breakpoint `k`. */ + isGreater: (k: MaybeRefOrGetter) => boolean; + /** Snapshot: viewport width is greater than or equal to breakpoint `k`. */ + isGreaterOrEqual: (k: MaybeRefOrGetter) => boolean; + /** Snapshot: viewport width is strictly smaller than breakpoint `k`. */ + isSmaller: (k: MaybeRefOrGetter) => boolean; + /** Snapshot: viewport width is smaller than or equal to breakpoint `k`. */ + isSmallerOrEqual: (k: MaybeRefOrGetter) => boolean; + /** Snapshot: viewport width is within `[a, b)`. */ + isInBetween: (a: MaybeRefOrGetter, b: MaybeRefOrGetter) => boolean; + /** Reactive: all currently active breakpoints, ordered small → large. */ + current: () => ComputedRef; + /** Reactive: the single active breakpoint per `strategy` (largest for `min-width`, smallest for `max-width`), or `''` when none. */ + active: () => ComputedRef; + }; + +/** + * Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`, `"50%"`) into a + * pixel number. `em`/`rem` use the conventional 16px root size. + */ +function pxValue(value: string): number { + const number = Number.parseFloat(value); + + if (Number.isNaN(number)) + return Number.NaN; + + if (/(?:em|rem)\s*$/i.test(value)) + return number * 16; + + return number; +} + +/** + * Add `delta` to the numeric portion of a CSS length, preserving its unit. + * Used to build the strict (`> / <`) variants from inclusive media queries via + * a small ±0.1 nudge. + */ +function increaseWithUnit(target: number | string, delta: number): number | string { + if (isNumber(target)) + return target + delta; + + const value = target.match(/^-?\d+(?:\.\d+)?/)?.[0] ?? ''; + const unit = target.slice(value.length); + const result = Number.parseFloat(value) + delta; + + if (Number.isNaN(result)) + return target; + + return result + unit; +} + +/** + * @name useBreakpoints + * @category Browser + * @description Reactive viewport breakpoints derived from a breakpoints map. + * SSR-safe (resolves width queries from `ssrWidth` before `matchMedia` exists), + * reactive to breakpoint values, and built on a single `useMediaQuery` per + * comparison. Comes with presets: `breakpointsTailwind`, `breakpointsBootstrapV5`, + * `breakpointsAntDesign`, `breakpointsVuetifyV3`. + * + * @param {Breakpoints} breakpoints The breakpoints map (`name → width`) + * @param {UseBreakpointsOptions} [options={}] Options (`strategy`, custom `window`, `ssrWidth`) + * @returns {UseBreakpointsReturn} Shortcut refs per breakpoint plus comparison helpers + * + * @example + * const bp = useBreakpoints(breakpointsTailwind); + * const isDesktop = bp.greaterOrEqual('lg'); + * const isMobile = bp.smaller('md'); + * bp.lg; // ComputedRef — true when viewport >= 1024px + * + * @example + * const bp = useBreakpoints({ mobile: 0, tablet: 640, desktop: 1024 }); + * const active = bp.active(); // ComputedRef<'mobile' | 'tablet' | 'desktop' | ''> + * + * @since 0.0.15 + */ +export function useBreakpoints( + breakpoints: Breakpoints, + options: UseBreakpointsOptions = {}, +): UseBreakpointsReturn { + const { window = defaultWindow, strategy = 'min-width', ssrWidth } = options; + const mediaOptions: UseMediaQueryOptions = { window, ssrWidth }; + const ssrSupport = isNumber(ssrWidth); + + function getValue(k: MaybeRefOrGetter, delta?: number): string { + let v = toValue(breakpoints[toValue(k)]); + + if (delta !== undefined) + v = increaseWithUnit(v, delta); + + return isNumber(v) ? `${v}px` : v; + } + + // Synchronous (non-reactive) match for the `is*` snapshot helpers. + function match(edge: 'min' | 'max', size: string): boolean { + const supported = window && isFunction(window.matchMedia); + + if (!supported) + return ssrSupport + ? (edge === 'min' ? ssrWidth >= pxValue(size) : ssrWidth <= pxValue(size)) + : false; + + return window.matchMedia(`(${edge}-width: ${size})`).matches; + } + + const greaterOrEqual = (k: MaybeRefOrGetter): ComputedRef => + useMediaQuery(() => `(min-width: ${getValue(k)})`, mediaOptions); + + const smallerOrEqual = (k: MaybeRefOrGetter): ComputedRef => + useMediaQuery(() => `(max-width: ${getValue(k)})`, mediaOptions); + + const greater = (k: MaybeRefOrGetter): ComputedRef => + useMediaQuery(() => `(min-width: ${getValue(k, 0.1)})`, mediaOptions); + + const smaller = (k: MaybeRefOrGetter): ComputedRef => + useMediaQuery(() => `(max-width: ${getValue(k, -0.1)})`, mediaOptions); + + const between = (a: MaybeRefOrGetter, b: MaybeRefOrGetter): ComputedRef => + useMediaQuery(() => `(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`, mediaOptions); + + const keys = Object.keys(breakpoints) as K[]; + + // Generated shortcut properties (`bp.lg`). Lazily created getters so we only + // spin up a `useMediaQuery` watcher for the breakpoints actually accessed. + const shortcuts = keys.reduce((acc, k) => { + Object.defineProperty(acc, k, { + get: () => strategy === 'min-width' ? greaterOrEqual(k) : smallerOrEqual(k), + enumerable: true, + configurable: true, + }); + + return acc; + }, {} as Record>); + + function current(): ComputedRef { + const points = keys + .map(k => [k, shortcuts[k], pxValue(getValue(k))] as const) + .sort((a, b) => a[2] - b[2]); + + return computed(() => points.filter(([, matches]) => matches.value).map(([k]) => k)); + } + + return Object.assign(shortcuts, { + greaterOrEqual, + smallerOrEqual, + greater, + smaller, + between, + isGreater: (k: MaybeRefOrGetter): boolean => match('min', getValue(k, 0.1)), + isGreaterOrEqual: (k: MaybeRefOrGetter): boolean => match('min', getValue(k)), + isSmaller: (k: MaybeRefOrGetter): boolean => match('max', getValue(k, -0.1)), + isSmallerOrEqual: (k: MaybeRefOrGetter): boolean => match('max', getValue(k)), + isInBetween: (a: MaybeRefOrGetter, b: MaybeRefOrGetter): boolean => + match('min', getValue(a)) && match('max', getValue(b, -0.1)), + current, + active(): ComputedRef { + const bps = current(); + + return computed(() => bps.value.length === 0 + ? '' + : bps.value.at(strategy === 'min-width' ? -1 : 0)!); + }, + }); +} + +/** + * Tailwind CSS default breakpoints. + * + * @see https://tailwindcss.com/docs/responsive-design + */ +export const breakpointsTailwind = { + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + '2xl': 1536, +}; + +/** + * Bootstrap v5 default breakpoints. + * + * @see https://getbootstrap.com/docs/5.0/layout/breakpoints/ + */ +export const breakpointsBootstrapV5 = { + xs: 0, + sm: 576, + md: 768, + lg: 992, + xl: 1200, + xxl: 1400, +}; + +/** + * Ant Design default breakpoints. + * + * @see https://ant.design/components/grid#col + */ +export const breakpointsAntDesign = { + xs: 480, + sm: 576, + md: 768, + lg: 992, + xl: 1200, + xxl: 1600, +}; + +/** + * Vuetify v3 default breakpoints. + * + * @see https://vuetifyjs.com/en/features/display-and-platform/ + */ +export const breakpointsVuetifyV3 = { + xs: 0, + sm: 600, + md: 960, + lg: 1280, + xl: 1920, + xxl: 2560, +}; diff --git a/vue/toolkit/src/composables/browser/useClipboard/index.ts b/vue/toolkit/src/composables/browser/useClipboard/index.ts index 54a564e..cb05383 100644 --- a/vue/toolkit/src/composables/browser/useClipboard/index.ts +++ b/vue/toolkit/src/composables/browser/useClipboard/index.ts @@ -3,9 +3,9 @@ import type { MaybeRefOrGetter, Ref } from 'vue'; import { isString } from '@robonen/stdlib'; import { defaultNavigator } from '@/types'; import type { ConfigurableNavigator } from '@/types'; -import { useSupported } from '@/composables/browser/useSupported'; +import { useSupported } from '@/composables/utilities/useSupported'; import { useEventListener } from '@/composables/browser/useEventListener'; -import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn'; +import { useTimeoutFn } from '@/composables/animation/useTimeoutFn'; /** * A value to copy: either a string or an (optionally async) getter that resolves to one. diff --git a/vue/toolkit/src/composables/browser/useClipboardItems/index.test.ts b/vue/toolkit/src/composables/browser/useClipboardItems/index.test.ts new file mode 100644 index 0000000..697171a --- /dev/null +++ b/vue/toolkit/src/composables/browser/useClipboardItems/index.test.ts @@ -0,0 +1,228 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import type { UseClipboardItemsReturn } from '.'; +import { useClipboardItems } from '.'; + +function makeItems(label: string): ClipboardItems { + // jsdom may lack ClipboardItem; a tagged plain object is enough for assertions. + return [{ types: [label] } as unknown as ClipboardItem]; +} + +function stubClipboard(readItems: ClipboardItems = makeItems('read')) { + const write = vi.fn(async () => {}); + const read = vi.fn(async () => readItems); + const navigator = { + clipboard: { write, read }, + } as unknown as Navigator; + return { navigator, write, read }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe(useClipboardItems, () => { + it('reports support when the clipboard API exists', () => { + const { navigator } = stubClipboard(); + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator }); + }); + expect(clip!.isSupported.value).toBeTruthy(); + scope.stop(); + }); + + it('is not supported without the clipboard API', () => { + const navigator = {} as unknown as Navigator; + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator }); + }); + expect(clip!.isSupported.value).toBeFalsy(); + scope.stop(); + }); + + it('is not supported when navigator is undefined (SSR)', () => { + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator: undefined }); + }); + expect(clip!.isSupported.value).toBeFalsy(); + scope.stop(); + }); + + it('copies items and sets copied flag', async () => { + const { navigator, write } = stubClipboard(); + const items = makeItems('copy'); + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator }); + }); + + await clip!.copy(items); + expect(write).toHaveBeenCalledWith(items); + expect(clip!.content.value).toBe(items); + expect(clip!.copied.value).toBeTruthy(); + scope.stop(); + }); + + it('copies the configured source when called without args', async () => { + const { navigator, write } = stubClipboard(); + const items = makeItems('source'); + const scope = effectScope(); + let clip: any; + scope.run(() => { + clip = useClipboardItems({ navigator, source: items }); + }); + + await clip.copy(); + expect(write).toHaveBeenCalledWith(items); + scope.stop(); + }); + + it('copies a value resolved from an async getter', async () => { + const { navigator, write } = stubClipboard(); + const items = makeItems('lazy'); + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator }); + }); + + await clip!.copy(async () => items); + expect(write).toHaveBeenCalledWith(items); + expect(clip!.content.value).toBe(items); + scope.stop(); + }); + + it('skips when an async getter resolves to undefined', async () => { + const { navigator, write } = stubClipboard(); + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator }); + }); + + await clip!.copy(async () => undefined); + expect(write).not.toHaveBeenCalled(); + expect(clip!.copied.value).toBeFalsy(); + scope.stop(); + }); + + it('exposes copyPending around an in-flight async copy', async () => { + const { navigator } = stubClipboard(); + const items = makeItems('done'); + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator }); + }); + + let release: (v: ClipboardItems) => void = () => {}; + const promise = clip!.copy(() => new Promise((resolve) => { + release = resolve; + })); + expect(clip!.copyPending.value).toBeTruthy(); + release(items); + await promise; + expect(clip!.copyPending.value).toBeFalsy(); + expect(clip!.content.value).toBe(items); + scope.stop(); + }); + + it('ignores a stale async copy superseded by a newer one', async () => { + const { navigator, write } = stubClipboard(); + const fastItems = makeItems('fast'); + const slowItems = makeItems('slow'); + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator }); + }); + + let releaseSlow: (v: ClipboardItems) => void = () => {}; + const slow = clip!.copy(() => new Promise((resolve) => { + releaseSlow = resolve; + })); + const fast = clip!.copy(async () => fastItems); + await fast; + releaseSlow(slowItems); + await slow; + + expect(clip!.content.value).toBe(fastItems); + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith(fastItems); + scope.stop(); + }); + + it('does nothing when unsupported', async () => { + const navigator = {} as unknown as Navigator; + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator }); + }); + + await clip!.copy(makeItems('x')); + expect(clip!.copied.value).toBeFalsy(); + scope.stop(); + }); + + it('reads the clipboard via read()', async () => { + const readItems = makeItems('from-clipboard'); + const { navigator, read } = stubClipboard(readItems); + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator }); + }); + + await clip!.read(); + expect(read).toHaveBeenCalled(); + expect(clip!.content.value).toBe(readItems); + scope.stop(); + }); + + it('syncs content on copy/cut events when read is enabled', async () => { + const readItems = makeItems('synced'); + const { navigator, read } = stubClipboard(readItems); + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator, read: true }); + }); + + globalThis.dispatchEvent(new Event('copy')); + await nextTick(); + await Promise.resolve(); + expect(read).toHaveBeenCalled(); + expect(clip!.content.value).toBe(readItems); + scope.stop(); + }); + + it('routes a rejected write to onError instead of throwing', async () => { + const error = new Error('denied'); + const write = vi.fn(async () => { + throw error; + }); + const navigator = { + clipboard: { write, read: vi.fn() }, + } as unknown as Navigator; + const onError = vi.fn(); + const scope = effectScope(); + let clip: UseClipboardItemsReturn; + scope.run(() => { + clip = useClipboardItems({ navigator, onError }); + }); + + await clip!.copy(makeItems('boom')); + expect(onError).toHaveBeenCalledWith(error); + expect(clip!.copied.value).toBeFalsy(); + expect(clip!.copyPending.value).toBeFalsy(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useClipboardItems/index.ts b/vue/toolkit/src/composables/browser/useClipboardItems/index.ts new file mode 100644 index 0000000..d9a96e8 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useClipboardItems/index.ts @@ -0,0 +1,185 @@ +import { shallowReadonly, shallowRef, toValue } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { isFunction, noop } from '@robonen/stdlib'; +import { defaultNavigator } from '@/types'; +import type { ConfigurableNavigator } from '@/types'; +import { useSupported } from '@/composables/utilities/useSupported'; +import { useEventListener } from '@/composables/browser/useEventListener'; +import { useTimeoutFn } from '@/composables/animation/useTimeoutFn'; + +/** + * A value to copy: either concrete `ClipboardItems` or an (optionally async) + * getter that resolves to them. + */ +export type ClipboardItemsValue + = | ClipboardItems + | (() => Promise | ClipboardItems | undefined); + +export interface UseClipboardItemsOptions extends ConfigurableNavigator { + /** + * Sync `content` with the system clipboard by listening to copy/cut events + * + * @default false + */ + read?: boolean; + + /** + * Default source value to copy when `copy()` is called without an argument + */ + source?: Source; + + /** + * Milliseconds the `copied` flag stays `true` after a copy + * + * @default 1500 + */ + copiedDuring?: number; + + /** + * Called when a read/write rejects, instead of throwing + * + * @default noop + */ + onError?: (error: unknown) => void; +} + +export interface UseClipboardItemsReturn { + /** + * Whether the async Clipboard API (with `ClipboardItem`) is available + */ + isSupported: Readonly>; + + /** + * The current clipboard items (kept in sync when `read` is enabled) + */ + content: Readonly>; + + /** + * `true` for `copiedDuring` ms after a successful copy + */ + copied: Readonly>; + + /** + * `true` while an async `copy()` is in flight + */ + copyPending: Readonly>; + + /** + * Copy clipboard items to the system clipboard + */ + copy: Optional extends true + ? (content?: ClipboardItemsValue) => Promise + : (content: ClipboardItemsValue) => Promise; + + /** + * Manually read the system clipboard into `content` + */ + read: () => Promise; +} + +/** + * @name useClipboardItems + * @category Browser + * @description Reactive async Clipboard API with rich `ClipboardItem` support + * (read/write images, HTML, and arbitrary MIME types — not just text). + * SSR-safe; uses passive `copy`/`cut` listeners and guards stale async writes. + * + * @param {UseClipboardItemsOptions} [options={}] Options + * @returns {UseClipboardItemsReturn} `isSupported`, `content`, `copied`, `copyPending`, `copy`, and `read` + * + * @example + * const { content, copy, copied, isSupported } = useClipboardItems(); + * copy([new ClipboardItem({ 'text/plain': new Blob(['hello'], { type: 'text/plain' }) })]); + * + * @example + * // Copy a lazily/asynchronously resolved value, kept in sync with the system clipboard + * const { content } = useClipboardItems({ read: true }); + * copy(async () => buildClipboardItems()); + * + * @since 0.0.15 + */ +export function useClipboardItems(options?: UseClipboardItemsOptions): UseClipboardItemsReturn; +export function useClipboardItems(options: UseClipboardItemsOptions>): UseClipboardItemsReturn; +export function useClipboardItems( + options: UseClipboardItemsOptions | undefined> = {}, +): UseClipboardItemsReturn { + const { + navigator = defaultNavigator, + read = false, + source, + copiedDuring = 1500, + onError = noop, + } = options; + + const isSupported = useSupported(() => navigator && 'clipboard' in navigator); + + const content = shallowRef([]); + const copied = shallowRef(false); + const copyPending = shallowRef(false); + + // Guards against a slow async copy clobbering the result of a newer one + let lastResolveId = 0; + + const timeout = useTimeoutFn(() => { + copied.value = false; + }, copiedDuring, { immediate: false }); + + async function updateContent(): Promise { + if (!isSupported.value) + return; + + try { + content.value = await navigator!.clipboard.read(); + } + catch (error) { + onError(error); + } + } + + if (isSupported.value && read) + useEventListener(['copy', 'cut'], updateContent, { passive: true }); + + async function copy(value: ClipboardItemsValue | undefined = toValue(source)): Promise { + if (!isSupported.value || value === null || value === undefined) + return; + + copyPending.value = true; + + try { + let resolved: ClipboardItems | undefined; + + if (isFunction(value)) { + const currentId = ++lastResolveId; + resolved = await value(); + + // Drop a stale async resolution superseded by a newer copy + if (resolved === null || resolved === undefined || currentId !== lastResolveId) + return; + } + else { + resolved = value; + } + + await navigator!.clipboard.write(resolved); + + content.value = resolved; + copied.value = true; + timeout.start(); + } + catch (error) { + onError(error); + } + finally { + copyPending.value = false; + } + } + + return { + isSupported, + content: shallowReadonly(content), + copied: shallowReadonly(copied), + copyPending: shallowReadonly(copyPending), + copy, + read: updateContent, + }; +} diff --git a/vue/toolkit/src/composables/browser/useCloseWatcher/index.ts b/vue/toolkit/src/composables/browser/useCloseWatcher/index.ts index fa66ae5..d80c731 100644 --- a/vue/toolkit/src/composables/browser/useCloseWatcher/index.ts +++ b/vue/toolkit/src/composables/browser/useCloseWatcher/index.ts @@ -3,7 +3,7 @@ import { noop } from '@robonen/stdlib'; import type { Ref } from 'vue'; import { defaultWindow } from '@/types'; import type { ConfigurableWindow } from '@/types'; -import { useSupported } from '@/composables/browser/useSupported'; +import { useSupported } from '@/composables/utilities/useSupported'; import { useEventListener } from '@/composables/browser/useEventListener'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; diff --git a/vue/toolkit/src/composables/browser/useColorMode/index.ts b/vue/toolkit/src/composables/browser/useColorMode/index.ts index d351b63..46ca5d6 100644 --- a/vue/toolkit/src/composables/browser/useColorMode/index.ts +++ b/vue/toolkit/src/composables/browser/useColorMode/index.ts @@ -186,12 +186,12 @@ export function useColorMode( let attributeToChange: { key: string; value: string } | null = null; if (attr === 'class') { - const next = value.split(/\s/g); + const next = new Set(value.split(/\s/g)); // Toggle only the classes this composable owns (derived from `modes`), // so unrelated classes on the element are left untouched. for (const owned of Object.values(modes).flatMap(mode => (mode || '').split(/\s/g)).filter(Boolean)) { - if (next.includes(owned)) + if (next.has(owned)) classesToAdd.add(owned); else classesToRemove.add(owned); diff --git a/vue/toolkit/src/composables/browser/useCssVar/index.test.ts b/vue/toolkit/src/composables/browser/useCssVar/index.test.ts new file mode 100644 index 0000000..669b512 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useCssVar/index.test.ts @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import { useCssVar } from '.'; + +let mutationInstances: Array<{ cb: MutationCallback; observe: ReturnType; disconnect: ReturnType }> = []; + +class StubMutationObserver { + observe = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(() => []); + cb: MutationCallback; + constructor(cb: MutationCallback) { + this.cb = cb; + mutationInstances.push(this); + } +} + +describe(useCssVar, () => { + afterEach(() => { + vi.unstubAllGlobals(); + mutationInstances = []; + }); + + it('reads the existing custom property from the target', () => { + const el = document.createElement('div'); + el.style.setProperty('--color', 'red'); + document.body.appendChild(el); + + const scope = effectScope(); + let color: ReturnType; + scope.run(() => { + color = useCssVar('--color', el); + }); + + expect(color!.value).toBe('red'); + scope.stop(); + }); + + it('writes the property to the element when set', async () => { + const el = document.createElement('div'); + document.body.appendChild(el); + + const scope = effectScope(); + let color: ReturnType; + scope.run(() => { + color = useCssVar('--color', el); + }); + + color!.value = 'blue'; + await nextTick(); + + expect(el.style.getPropertyValue('--color')).toBe('blue'); + expect(color!.value).toBe('blue'); + scope.stop(); + }); + + it('removes the property when set to null', () => { + const el = document.createElement('div'); + el.style.setProperty('--color', 'green'); + document.body.appendChild(el); + + const scope = effectScope(); + let color: ReturnType; + scope.run(() => { + color = useCssVar('--color', el); + }); + + color!.value = null; + + expect(el.style.getPropertyValue('--color')).toBe(''); + expect(color!.value).toBeNull(); + scope.stop(); + }); + + it('falls back to initialValue when the property is unset', () => { + const el = document.createElement('div'); + document.body.appendChild(el); + + const scope = effectScope(); + let color: ReturnType; + scope.run(() => { + color = useCssVar('--missing', el, { initialValue: 'gray' }); + }); + + expect(color!.value).toBe('gray'); + scope.stop(); + }); + + it('defaults the target to document.documentElement', () => { + document.documentElement.style.setProperty('--root-var', 'rootval'); + + const scope = effectScope(); + let v: ReturnType; + scope.run(() => { + v = useCssVar('--root-var'); + }); + + expect(v!.value).toBe('rootval'); + v!.value = 'changed'; + expect(document.documentElement.style.getPropertyValue('--root-var')).toBe('changed'); + + document.documentElement.style.removeProperty('--root-var'); + scope.stop(); + }); + + it('removes the old property and re-reads when the prop name changes', async () => { + const el = document.createElement('div'); + el.style.setProperty('--a', 'one'); + el.style.setProperty('--b', 'two'); + document.body.appendChild(el); + + const prop = ref('--a'); + const scope = effectScope(); + let v: ReturnType; + scope.run(() => { + v = useCssVar(prop, el); + }); + + expect(v!.value).toBe('one'); + + prop.value = '--b'; + await nextTick(); + + expect(el.style.getPropertyValue('--a')).toBe(''); + expect(v!.value).toBe('two'); + scope.stop(); + }); + + it('re-reads when a reactive target changes', async () => { + const a = document.createElement('div'); + a.style.setProperty('--c', 'fromA'); + const b = document.createElement('div'); + b.style.setProperty('--c', 'fromB'); + document.body.append(a, b); + + const target = ref(a); + const scope = effectScope(); + let v: ReturnType; + scope.run(() => { + v = useCssVar('--c', target); + }); + + expect(v!.value).toBe('fromA'); + + target.value = b; + await nextTick(); + + expect(v!.value).toBe('fromB'); + scope.stop(); + }); + + it('attaches a MutationObserver only when observe is true', () => { + vi.stubGlobal('MutationObserver', StubMutationObserver); + const el = document.createElement('div'); + document.body.appendChild(el); + + const scopeOff = effectScope(); + scopeOff.run(() => useCssVar('--x', el)); + expect(mutationInstances).toHaveLength(0); + scopeOff.stop(); + + const scopeOn = effectScope(); + scopeOn.run(() => useCssVar('--x', el, { observe: true })); + expect(mutationInstances).toHaveLength(1); + expect(mutationInstances[0]!.observe).toHaveBeenCalledWith( + el, + expect.objectContaining({ attributeFilter: ['style', 'class'] }), + ); + scopeOn.stop(); + }); + + it('updates the ref when an observed mutation fires', () => { + vi.stubGlobal('MutationObserver', StubMutationObserver); + const el = document.createElement('div'); + el.style.setProperty('--y', 'initial'); + document.body.appendChild(el); + + const scope = effectScope(); + let v: ReturnType; + scope.run(() => { + v = useCssVar('--y', el, { observe: true }); + }); + + expect(v!.value).toBe('initial'); + + // Simulate an external change followed by a mutation record. + el.style.setProperty('--y', 'external'); + mutationInstances[0]!.cb([{ type: 'attributes' } as MutationRecord], mutationInstances[0] as unknown as MutationObserver); + + expect(v!.value).toBe('external'); + scope.stop(); + }); + + it('does not throw and keeps initialValue under SSR (no window)', () => { + const scope = effectScope(); + let v: ReturnType; + scope.run(() => { + v = useCssVar('--ssr', null, { + initialValue: 'fallback', + window: undefined as unknown as Window, + }); + }); + + expect(v!.value).toBe('fallback'); + // Writing is a no-op on the (missing) element but still updates the store. + v!.value = 'next'; + expect(v!.value).toBe('next'); + scope.stop(); + }); + + it('uses a custom window from options for getComputedStyle', () => { + const el = document.createElement('div'); + document.body.appendChild(el); + + const getComputedStyle = vi.fn(() => ({ + getPropertyValue: () => ' spaced ', + })) as unknown as Window['getComputedStyle']; + + const fakeWindow = { + getComputedStyle, + document: { documentElement: el }, + } as unknown as Window; + + const scope = effectScope(); + let v: ReturnType; + scope.run(() => { + v = useCssVar('--z', el, { window: fakeWindow }); + }); + + expect(getComputedStyle).toHaveBeenCalledWith(el); + expect(v!.value).toBe('spaced'); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useCssVar/index.ts b/vue/toolkit/src/composables/browser/useCssVar/index.ts new file mode 100644 index 0000000..9cb7181 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useCssVar/index.ts @@ -0,0 +1,112 @@ +import { computed, shallowRef, toValue, watch } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter, WritableComputedRef } from 'vue'; +import type { ConfigurableWindow } from '@/types'; +import { defaultWindow } from '@/types'; +import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; +import { unrefElement } from '@/composables/component/unrefElement'; +import { useMutationObserver } from '@/composables/elements/useMutationObserver'; + +export interface UseCssVarOptions extends ConfigurableWindow { + /** + * Value used before the variable resolves (and the fallback when the + * computed value is empty). + */ + initialValue?: string; + /** + * Watch the target with a `MutationObserver` (filtered to `style`/`class`) + * so the ref reflects external changes to the variable. + * + * @default false + */ + observe?: boolean; +} + +export interface UseCssVarReturn extends WritableComputedRef {} + +/** + * @name useCssVar + * @category Browser + * @description Read and write a CSS custom property on an element as a reactive ref. + * Defaults to `document.documentElement`. Set `observe` to react to external + * changes via a `MutationObserver`. + * + * @param {MaybeRefOrGetter} prop The CSS variable name (e.g. `--color`) + * @param {MaybeComputedElementRef} [target] Element to read/write the variable on (defaults to `documentElement`) + * @param {UseCssVarOptions} [options={}] `initialValue`, `observe`, and a configurable `window` + * @returns {UseCssVarReturn} A writable ref; reading returns the current value, writing updates the property + * + * @example + * const color = useCssVar('--color', el); + * color.value = 'red'; + * + * @example + * const theme = useCssVar('--theme', null, { initialValue: 'light', observe: true }); + * + * @since 0.0.15 + */ +export function useCssVar( + prop: MaybeRefOrGetter, + target?: MaybeComputedElementRef, + options: UseCssVarOptions = {}, +): UseCssVarReturn { + const { window = defaultWindow, initialValue, observe = false } = options; + + // Backing store: only mutated on explicit reads / observed changes, + // so consumers reading `.value` never pay for `getComputedStyle`. + const store = shallowRef(initialValue); + + const elRef: ComputedRef = computed( + () => (unrefElement(target) as HTMLElement | SVGElement | undefined) ?? window?.document?.documentElement, + ); + + const read = (): void => { + const el = elRef.value; + const key = toValue(prop); + + if (!el || !window || !key) + return; + + const value = window.getComputedStyle(el).getPropertyValue(key)?.trim(); + store.value = value || store.value || initialValue; + }; + + const write = (value: string | null | undefined): void => { + const el = elRef.value; + const key = toValue(prop); + + store.value = value; + + if (!el?.style || !key) + return; + + if (value === null || value === undefined) + el.style.removeProperty(key); + else + el.style.setProperty(key, value); + }; + + if (observe) { + useMutationObserver(elRef, read, { + attributeFilter: ['style', 'class'], + window, + }); + } + + // Single watcher: when the element or prop changes, drop the old custom + // property and re-read the current value. + watch( + () => [elRef.value, toValue(prop)] as const, + ([el, key], old) => { + const [oldEl, oldKey] = old ?? []; + if (oldEl?.style && oldKey && (oldEl !== el || oldKey !== key)) + oldEl.style.removeProperty(oldKey); + read(); + }, + { immediate: true }, + ); + + return computed({ + get: () => store.value, + set: write, + }); +} diff --git a/vue/toolkit/src/composables/browser/useDark/index.test.ts b/vue/toolkit/src/composables/browser/useDark/index.test.ts new file mode 100644 index 0000000..96a5359 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDark/index.test.ts @@ -0,0 +1,330 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { useDark } from '.'; + +type Listener = (event: { matches: boolean }) => void; + +interface StubMql { + readonly matches: boolean; + media: string; + addEventListener: (type: string, cb: Listener) => void; + removeEventListener: (type: string, cb: Listener) => void; + dispatch: (value: boolean) => void; +} + +function makeMql(initialMatches: boolean, media = ''): StubMql { + const listeners = new Set(); + let matches = initialMatches; + + return { + get matches() { + return matches; + }, + media, + addEventListener: (_: string, cb: Listener) => listeners.add(cb), + removeEventListener: (_: string, cb: Listener) => listeners.delete(cb), + dispatch(value: boolean) { + matches = value; + for (const cb of listeners) cb({ matches: value }); + }, + }; +} + +/** + * Build a stub `window` that reuses the real jsdom `document` (so DOM updates + * applied to `` are observable) but with a controllable `matchMedia` for + * `prefers-color-scheme: dark`, an isolated in-memory `localStorage`, and a + * `getComputedStyle` shim for the transition-disabling reflow. + */ +function makeWindow(prefersDark: StubMql) { + const map = new Map(); + + const storage: Storage = { + getItem: (key: string) => (map.has(key) ? map.get(key)! : null), + setItem: (key: string, value: string) => { map.set(key, String(value)); }, + removeItem: (key: string) => { map.delete(key); }, + clear: () => map.clear(), + key: (index: number) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; + + const win = { + document: globalThis.document, + matchMedia: vi.fn((query: string) => + query.includes('dark') ? prefersDark : makeMql(false, query)), + localStorage: storage, + getComputedStyle: () => ({ opacity: '1' }), + dispatchEvent: () => true, + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as Window & typeof globalThis; + + return { win, storage, map }; +} + +function reset() { + document.documentElement.className = ''; + document.documentElement.removeAttribute('data-theme'); +} + +describe(useDark, () => { + beforeEach(() => { + reset(); + // Ensure module-captured defaultWindow.matchMedia is undefined so the + // composable must use the injected window. + vi.stubGlobal('matchMedia', undefined); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + reset(); + }); + + it('reflects the system preference in auto mode (dark)', async () => { + const prefersDark = makeMql(true); + const { win } = makeWindow(prefersDark); + const scope = effectScope(); + let isDark: ReturnType; + + scope.run(() => { + isDark = useDark({ window: win }); + }); + await nextTick(); + + expect(isDark!.value).toBeTruthy(); + expect(document.documentElement.classList.contains('dark')).toBeTruthy(); + + scope.stop(); + }); + + it('reflects the system preference in auto mode (light)', async () => { + const prefersDark = makeMql(false); + const { win } = makeWindow(prefersDark); + const scope = effectScope(); + let isDark: ReturnType; + + scope.run(() => { + isDark = useDark({ window: win }); + }); + await nextTick(); + + expect(isDark!.value).toBeFalsy(); + // Default valueLight is '' so no light class is applied. + expect(document.documentElement.classList.contains('dark')).toBeFalsy(); + + scope.stop(); + }); + + it('writing true while the system prefers light applies the dark class', async () => { + const prefersDark = makeMql(false); + const { win } = makeWindow(prefersDark); + const scope = effectScope(); + let isDark: ReturnType; + + scope.run(() => { + isDark = useDark({ window: win }); + }); + await nextTick(); + + expect(isDark!.value).toBeFalsy(); + + isDark!.value = true; + await nextTick(); + + expect(isDark!.value).toBeTruthy(); + expect(document.documentElement.classList.contains('dark')).toBeTruthy(); + + scope.stop(); + }); + + it('writing true while the system prefers dark falls back to auto', async () => { + const prefersDark = makeMql(true); + const { win, storage } = makeWindow(prefersDark); + const scope = effectScope(); + let isDark: ReturnType; + + scope.run(() => { + // Start from an explicit light value so the store is not already auto. + isDark = useDark({ window: win }); + }); + await nextTick(); + + isDark!.value = false; + await nextTick(); + expect(storage.getItem('vuetools-color-scheme')).toBe('light'); + + // System prefers dark, so requesting dark should resolve to 'auto'. + isDark!.value = true; + await nextTick(); + + expect(isDark!.value).toBeTruthy(); + expect(storage.getItem('vuetools-color-scheme')).toBe('auto'); + + scope.stop(); + }); + + it('writing false while the system prefers dark falls back to auto', async () => { + const prefersDark = makeMql(false); + const { win, storage } = makeWindow(prefersDark); + const scope = effectScope(); + let isDark: ReturnType; + + scope.run(() => { + isDark = useDark({ window: win }); + }); + await nextTick(); + + // System prefers light, so requesting light resolves to 'auto'. + isDark!.value = false; + await nextTick(); + + expect(isDark!.value).toBeFalsy(); + expect(storage.getItem('vuetools-color-scheme')).toBe('auto'); + + scope.stop(); + }); + + it('reacts to system preference changes while in auto mode', async () => { + const prefersDark = makeMql(false); + const { win } = makeWindow(prefersDark); + const scope = effectScope(); + let isDark: ReturnType; + + scope.run(() => { + isDark = useDark({ window: win }); + }); + await nextTick(); + + expect(isDark!.value).toBeFalsy(); + + prefersDark.dispatch(true); + await nextTick(); + + expect(isDark!.value).toBeTruthy(); + expect(document.documentElement.classList.contains('dark')).toBeTruthy(); + + scope.stop(); + }); + + it('honours custom valueDark / valueLight on a custom attribute', async () => { + const prefersDark = makeMql(false); + const { win } = makeWindow(prefersDark); + const scope = effectScope(); + let isDark: ReturnType; + + scope.run(() => { + isDark = useDark({ + window: win, + attribute: 'data-theme', + valueDark: 'night', + valueLight: 'day', + }); + }); + await nextTick(); + + expect(document.documentElement.getAttribute('data-theme')).toBe('day'); + + isDark!.value = true; + await nextTick(); + + expect(document.documentElement.getAttribute('data-theme')).toBe('night'); + + scope.stop(); + }); + + it('persists to a custom storageKey', async () => { + const prefersDark = makeMql(false); + const { win, storage } = makeWindow(prefersDark); + const scope = effectScope(); + let isDark: ReturnType; + + scope.run(() => { + isDark = useDark({ window: win, storageKey: 'my-dark' }); + }); + await nextTick(); + + isDark!.value = true; + await nextTick(); + + expect(storage.getItem('my-dark')).toBe('dark'); + expect(storage.getItem('vuetools-color-scheme')).toBeNull(); + + scope.stop(); + }); + + it('uses a custom storage backend', async () => { + const prefersDark = makeMql(false); + const { win } = makeWindow(prefersDark); + const map = new Map(); + const storage: Storage = { + getItem: (key: string) => (map.has(key) ? map.get(key)! : null), + setItem: (key: string, value: string) => { map.set(key, String(value)); }, + removeItem: (key: string) => { map.delete(key); }, + clear: () => map.clear(), + key: (index: number) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; + const scope = effectScope(); + let isDark: ReturnType; + + scope.run(() => { + isDark = useDark({ window: win, storage }); + }); + await nextTick(); + + isDark!.value = true; + await nextTick(); + + expect(map.get('vuetools-color-scheme')).toBe('dark'); + + scope.stop(); + }); + + it('invokes a custom onChanged handler with the boolean state', async () => { + const prefersDark = makeMql(true); + const { win } = makeWindow(prefersDark); + const onChanged = vi.fn(); + const scope = effectScope(); + + scope.run(() => { + useDark({ window: win, onChanged }); + }); + await nextTick(); + + expect(onChanged).toHaveBeenCalled(); + const [boolValue, handler, mode] = onChanged.mock.calls[0]!; + expect(boolValue).toBeTruthy(); + expect(typeof handler).toBe('function'); + expect(mode).toBe('dark'); + // Default handler suppressed: no class applied. + expect(document.documentElement.classList.contains('dark')).toBeFalsy(); + + scope.stop(); + }); + + it('does not throw on the SSR/unsupported path (no window)', async () => { + const scope = effectScope(); + let isDark: ReturnType; + + expect(() => { + scope.run(() => { + isDark = useDark({ window: undefined }); + }); + }).not.toThrow(); + await nextTick(); + + // System detection unavailable -> defaults to light -> isDark false. + expect(isDark!.value).toBeFalsy(); + + // Still writable in memory. + isDark!.value = true; + await nextTick(); + expect(isDark!.value).toBeTruthy(); + + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useDark/index.ts b/vue/toolkit/src/composables/browser/useDark/index.ts new file mode 100644 index 0000000..5f0ec41 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDark/index.ts @@ -0,0 +1,97 @@ +import { computed } from 'vue'; +import type { WritableComputedRef } from 'vue'; +import { useColorMode } from '@/composables/browser/useColorMode'; +import type { BasicColorSchema, UseColorModeOptions } from '@/composables/browser/useColorMode'; + +export interface UseDarkOptions extends Omit, 'modes' | 'onChanged'> { + /** + * Value applied to the target element when `isDark` is `true`. + * + * @default 'dark' + */ + valueDark?: string; + + /** + * Value applied to the target element when `isDark` is `false`. + * + * @default '' + */ + valueLight?: string; + + /** + * Custom handler called whenever the resolved mode changes. When specified, + * the default DOM update is overridden (call `defaultHandler` to keep it). + * + * @default undefined + */ + onChanged?: (isDark: boolean, defaultHandler: (mode: BasicColorSchema) => void, mode: BasicColorSchema) => void; +} + +export type UseDarkReturn = WritableComputedRef; + +/** + * @name useDark + * @category Browser + * @description Reactive dark mode boolean with system detection and storage + * persistence, built on `useColorMode`. Writing `false` while the system + * already prefers light (or `true` while it prefers dark) falls back to + * `'auto'`, so the mode keeps tracking the OS preference. + * + * @param {UseDarkOptions} [options={}] Options + * @returns {UseDarkReturn} A writable boolean ref; `true` when dark mode is active + * + * @example + * const isDark = useDark(); + * isDark.value = true; + * + * @example + * // Toggle a data attribute instead of a class + * const isDark = useDark({ + * attribute: 'data-theme', + * valueDark: 'dark', + * valueLight: 'light', + * }); + * + * @example + * import { useToggle } from '@/composables/state/useToggle'; + * + * const isDark = useDark(); + * const toggleDark = useToggle(isDark); + * + * @since 0.0.15 + */ +export function useDark(options: UseDarkOptions = {}): UseDarkReturn { + const { + valueDark = 'dark', + valueLight = '', + } = options; + + const mode = useColorMode({ + ...options, + onChanged: (resolved, defaultHandler) => { + if (options.onChanged) + options.onChanged(resolved === 'dark', defaultHandler, resolved); + else + defaultHandler(resolved); + }, + modes: { + dark: valueDark, + light: valueLight, + }, + }); + + const isDark = computed({ + get() { + return mode.state.value === 'dark'; + }, + set(value) { + const next = value ? 'dark' : 'light'; + + // When the requested state already matches the system preference, fall + // back to `'auto'` so the mode keeps following the OS going forward. + mode.value = mode.system.value === next ? 'auto' : next; + }, + }); + + return isDark; +} diff --git a/vue/toolkit/src/composables/browser/useDocumentPiP/index.test.ts b/vue/toolkit/src/composables/browser/useDocumentPiP/index.test.ts new file mode 100644 index 0000000..d456fde --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDocumentPiP/index.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { useDocumentPiP } from '.'; +import type { DocumentPictureInPictureOptions } from '.'; + +/** + * Build a fake Picture-in-Picture `Window` that records its listeners and + * `close()` call. Passed through options so it reaches the import-time-captured + * `defaultWindow` substitute (see test gotcha). + */ +function createPipWindow() { + const listeners = new Map(); + + const pip = { + addEventListener: vi.fn((type: string, listener: EventListener) => { + listeners.set(type, listener); + }), + close: vi.fn(), + } as unknown as Window; + + function firePagehide() { + listeners.get('pagehide')?.(new Event('pagehide')); + } + + return { pip, firePagehide, close: pip.close as ReturnType }; +} + +function createWindow(pip: Window) { + const requestWindow = vi.fn(async (_options?: DocumentPictureInPictureOptions) => pip); + const win = { + documentPictureInPicture: { window: null, requestWindow }, + } as unknown as Window & typeof globalThis; + + return { window: win, requestWindow }; +} + +describe(useDocumentPiP, () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reports supported when documentPictureInPicture exists on window', () => { + const scope = effectScope(); + const { pip } = createPipWindow(); + const { window } = createWindow(pip); + + let result: ReturnType; + scope.run(() => { + result = useDocumentPiP({ window }); + }); + + expect(result!.isSupported.value).toBeTruthy(); + scope.stop(); + }); + + it('reports unsupported when the API is absent', () => { + const scope = effectScope(); + const win = {} as unknown as Window & typeof globalThis; + + let result: ReturnType; + scope.run(() => { + result = useDocumentPiP({ window: win }); + }); + + expect(result!.isSupported.value).toBeFalsy(); + scope.stop(); + }); + + it('is SSR safe when window is undefined', async () => { + const scope = effectScope(); + + let result: ReturnType; + scope.run(() => { + result = useDocumentPiP({ window: undefined as unknown as Window }); + }); + + expect(result!.isSupported.value).toBeFalsy(); + await expect(result!.open()).resolves.toBeUndefined(); + scope.stop(); + }); + + it('opens a PiP window, tracks it, and forwards options', async () => { + const scope = effectScope(); + const { pip } = createPipWindow(); + const { window, requestWindow } = createWindow(pip); + + let result: ReturnType; + scope.run(() => { + result = useDocumentPiP({ window }); + }); + + const returned = await result!.open({ width: 320, height: 240 }); + await nextTick(); + + expect(requestWindow).toHaveBeenCalledWith({ width: 320, height: 240 }); + expect(returned).toBe(pip); + expect(result!.pipWindow.value).toBe(pip); + expect(result!.isOpen.value).toBeTruthy(); + scope.stop(); + }); + + it('clears the reference when the PiP window emits pagehide', async () => { + const scope = effectScope(); + const { pip, firePagehide } = createPipWindow(); + const { window } = createWindow(pip); + + let result: ReturnType; + scope.run(() => { + result = useDocumentPiP({ window }); + }); + + await result!.open(); + expect(result!.isOpen.value).toBeTruthy(); + + firePagehide(); + await nextTick(); + + expect(result!.pipWindow.value).toBeNull(); + expect(result!.isOpen.value).toBeFalsy(); + scope.stop(); + }); + + it('close() closes the window and clears state', async () => { + const scope = effectScope(); + const { pip, close } = createPipWindow(); + const { window } = createWindow(pip); + + let result: ReturnType; + scope.run(() => { + result = useDocumentPiP({ window }); + }); + + await result!.open(); + result!.close(); + + expect(close).toHaveBeenCalledTimes(1); + expect(result!.pipWindow.value).toBeNull(); + scope.stop(); + }); + + it('closes the PiP window when the scope is disposed', async () => { + const scope = effectScope(); + const { pip, close } = createPipWindow(); + const { window } = createWindow(pip); + + let result: ReturnType; + scope.run(() => { + result = useDocumentPiP({ window }); + }); + + await result!.open(); + scope.stop(); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it('stores the error and invokes onError when open() rejects', async () => { + const scope = effectScope(); + const error = new DOMException('gesture required', 'NotAllowedError'); + const requestWindow = vi.fn(async () => { + throw error; + }); + const win = { + documentPictureInPicture: { window: null, requestWindow }, + } as unknown as Window & typeof globalThis; + const onError = vi.fn(); + + let result: ReturnType; + scope.run(() => { + result = useDocumentPiP({ window: win, onError }); + }); + + await expect(result!.open()).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith(error); + expect(result!.error.value).toBe(error); + expect(result!.isOpen.value).toBeFalsy(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useDocumentPiP/index.ts b/vue/toolkit/src/composables/browser/useDocumentPiP/index.ts new file mode 100644 index 0000000..4ecd098 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useDocumentPiP/index.ts @@ -0,0 +1,169 @@ +import { computed, shallowRef } from 'vue'; +import type { ComputedRef, ShallowRef } from 'vue'; +import { noop } from '@robonen/stdlib'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useSupported } from '@/composables/utilities/useSupported'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; + +export interface DocumentPictureInPictureOptions { + /** + * The initial width of the Picture-in-Picture window, in pixels. + */ + width?: number; + + /** + * The initial height of the Picture-in-Picture window, in pixels. + */ + height?: number; + + /** + * Hide the "back to tab" button in the Picture-in-Picture window. + * + * @default false + */ + disallowReturnToOpener?: boolean; + + /** + * Open the window in its default position/size rather than reusing the last one. + * + * @default false + */ + preferInitialWindowPlacement?: boolean; +} + +interface DocumentPictureInPicture { + readonly window: Window | null; + requestWindow: (options?: DocumentPictureInPictureOptions) => Promise; +} + +interface WindowWithDocumentPiP { + documentPictureInPicture: DocumentPictureInPicture; +} + +export interface UseDocumentPiPOptions extends ConfigurableWindow { + /** + * Called when `open()` rejects (e.g. not triggered by a user gesture) instead + * of throwing. The same value is also stored in the returned `error` ref. + * + * @default noop + */ + onError?: (error: unknown) => void; +} + +export interface UseDocumentPiPReturn { + /** + * Whether the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) is supported + */ + isSupported: ComputedRef; + + /** + * The active Picture-in-Picture `Window`, or `null` when none is open + */ + pipWindow: ShallowRef; + + /** + * Whether a Picture-in-Picture window is currently open + */ + isOpen: ComputedRef; + + /** + * The last error thrown by `open()`, or `null` + */ + error: ShallowRef; + + /** + * Open a Picture-in-Picture window. Must be called from a user gesture. + * Resolves with the new `Window`, or `undefined` when unsupported. + */ + open: (pipOptions?: DocumentPictureInPictureOptions) => Promise; + + /** + * Close the active Picture-in-Picture window, if any + */ + close: () => void; +} + +/** + * @name useDocumentPiP + * @category Browser + * @description Reactive wrapper around the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) for rendering arbitrary DOM in an always-on-top window. + * + * @param {UseDocumentPiPOptions} [options={}] Options + * @param {Function} [options.onError=noop] Error callback invoked instead of throwing + * @param {Window} [options.window=defaultWindow] Custom `window` instance + * @returns {UseDocumentPiPReturn} `isSupported`, `pipWindow`, `isOpen`, `error`, `open()`, and `close()` + * + * @example + * const { isSupported, pipWindow, open } = useDocumentPiP(); + * async function popOut(content: HTMLElement) { + * const win = await open({ width: 320, height: 240 }); + * win?.document.body.append(content); + * } + * + * @example + * // Move a player into the PiP window and track open state + * const { isOpen, pipWindow, open, close } = useDocumentPiP(); + * watchEffect(() => { + * if (pipWindow.value) + * pipWindow.value.document.body.append(playerEl); + * }); + * + * @since 0.0.15 + */ +export function useDocumentPiP(options: UseDocumentPiPOptions = {}): UseDocumentPiPReturn { + const { + window = defaultWindow, + onError = noop, + } = options; + + const isSupported = useSupported(() => !!window && 'documentPictureInPicture' in window); + const pipWindow = shallowRef(null); + const error = shallowRef(null); + + const isOpen = computed(() => pipWindow.value !== null); + + function handleClose(): void { + pipWindow.value = null; + } + + async function open(pipOptions?: DocumentPictureInPictureOptions): Promise { + if (!isSupported.value || !window) + return undefined; + + error.value = null; + + try { + const controller = (window as unknown as WindowWithDocumentPiP).documentPictureInPicture; + const pip = await controller.requestWindow(pipOptions); + + // The PiP window closing (user or programmatic) clears our reference. + pip.addEventListener('pagehide', handleClose, { once: true }); + pipWindow.value = pip; + + return pip; + } + catch (err) { + error.value = err; + onError(err); + + return undefined; + } + } + + function close(): void { + pipWindow.value?.close(); + pipWindow.value = null; + } + + tryOnScopeDispose(close); + + return { + isSupported, + pipWindow, + isOpen, + error, + open, + close, + }; +} diff --git a/vue/toolkit/src/composables/browser/useEyeDropper/index.ts b/vue/toolkit/src/composables/browser/useEyeDropper/index.ts index 6d0a348..9fe9d79 100644 --- a/vue/toolkit/src/composables/browser/useEyeDropper/index.ts +++ b/vue/toolkit/src/composables/browser/useEyeDropper/index.ts @@ -2,7 +2,7 @@ import { shallowRef } from 'vue'; import type { ComputedRef, ShallowRef } from 'vue'; import { defaultWindow } from '@/types'; import type { ConfigurableWindow } from '@/types'; -import { useSupported } from '@/composables/browser/useSupported'; +import { useSupported } from '@/composables/utilities/useSupported'; export interface EyeDropperOpenOptions { /** diff --git a/vue/toolkit/src/composables/browser/useFileSystemAccess/index.test.ts b/vue/toolkit/src/composables/browser/useFileSystemAccess/index.test.ts new file mode 100644 index 0000000..ba09b05 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFileSystemAccess/index.test.ts @@ -0,0 +1,314 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick, ref } from 'vue'; +import type { FileSystemFileHandle, UseFileSystemAccessReturn } from '.'; +import { useFileSystemAccess } from '.'; + +interface WritableSpy { + write: ReturnType; + close: ReturnType; +} + +function makeFile(contents = 'hello world', name = 'demo.txt', type = 'text/plain'): File { + const file = { + name, + type, + size: contents.length, + lastModified: 1234, + text: vi.fn(async () => contents), + arrayBuffer: vi.fn(async () => new ArrayBuffer(contents.length)), + }; + return file as unknown as File; +} + +function makeHandle(file: File): { handle: FileSystemFileHandle; writable: WritableSpy } { + const writable: WritableSpy = { + write: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; + const handle = { + getFile: vi.fn(async () => file), + createWritable: vi.fn(async () => writable), + } as unknown as FileSystemFileHandle; + return { handle, writable }; +} + +function stubWindow(handle?: FileSystemFileHandle) { + const showOpenFilePicker = vi.fn(async () => (handle ? [handle] : [])); + const showSaveFilePicker = vi.fn(async () => handle); + const window = { + showOpenFilePicker, + showSaveFilePicker, + } as unknown as Window; + return { window, showOpenFilePicker, showSaveFilePicker }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe(useFileSystemAccess, () => { + it('reports support when the picker APIs exist', () => { + const { window } = stubWindow(); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window }); + }); + expect(fsa!.isSupported.value).toBeTruthy(); + scope.stop(); + }); + + it('is not supported without the picker APIs', () => { + const window = {} as unknown as Window; + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window }); + }); + expect(fsa!.isSupported.value).toBeFalsy(); + scope.stop(); + }); + + it('is not supported and is a no-op under SSR (no window)', async () => { + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window: undefined }); + }); + expect(fsa!.isSupported.value).toBeFalsy(); + await expect(fsa!.open()).resolves.toBeUndefined(); + await expect(fsa!.save()).resolves.toBeUndefined(); + expect(fsa!.fileName.value).toBe(''); + expect(fsa!.fileSize.value).toBe(0); + expect(fsa!.data.value).toBeUndefined(); + scope.stop(); + }); + + it('opens a file and reads it as text by default', async () => { + const file = makeFile('content'); + const { handle } = makeHandle(file); + const { window, showOpenFilePicker } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, dataType: 'Text' }); + }); + + await fsa!.open(); + expect(showOpenFilePicker).toHaveBeenCalledOnce(); + expect(fsa!.data.value).toBe('content'); + expect(fsa!.file.value).toBe(file); + expect(fsa!.fileName.value).toBe('demo.txt'); + expect(fsa!.fileMIME.value).toBe('text/plain'); + expect(fsa!.fileSize.value).toBe(7); + expect(fsa!.fileLastModified.value).toBe(1234); + scope.stop(); + }); + + it('reads as ArrayBuffer when dataType is ArrayBuffer', async () => { + const file = makeFile('abc'); + const { handle } = makeHandle(file); + const { window } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, dataType: 'ArrayBuffer' }); + }); + + await fsa!.open(); + expect(fsa!.data.value).toBeInstanceOf(ArrayBuffer); + expect(file.arrayBuffer).toHaveBeenCalled(); + scope.stop(); + }); + + it('exposes the File itself when dataType is Blob', async () => { + const file = makeFile('abc'); + const { handle } = makeHandle(file); + const { window } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, dataType: 'Blob' }); + }); + + await fsa!.open(); + expect(fsa!.data.value).toBe(file); + scope.stop(); + }); + + it('passes types and excludeAcceptAllOption to the open picker', async () => { + const file = makeFile(); + const { handle } = makeHandle(file); + const { window, showOpenFilePicker } = stubWindow(handle); + const types = [{ description: 'text', accept: { 'text/plain': ['.txt'] } }]; + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, types, excludeAcceptAllOption: true }); + }); + + await fsa!.open(); + expect(showOpenFilePicker).toHaveBeenCalledWith({ types, excludeAcceptAllOption: true }); + scope.stop(); + }); + + it('lets per-call open options override the defaults', async () => { + const file = makeFile(); + const { handle } = makeHandle(file); + const { window, showOpenFilePicker } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, excludeAcceptAllOption: false }); + }); + + await fsa!.open({ excludeAcceptAllOption: true }); + expect(showOpenFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: true }); + scope.stop(); + }); + + it('creates a new empty handle and clears prior data', async () => { + const file = makeFile(''); + const { handle } = makeHandle(file); + const { window, showSaveFilePicker } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window }); + }); + + await fsa!.create({ suggestedName: 'new.txt' }); + expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'new.txt' }); + expect(fsa!.file.value).toBe(file); + scope.stop(); + }); + + it('save writes current data to the existing handle', async () => { + const file = makeFile('original'); + const { handle, writable } = makeHandle(file); + const { window } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, dataType: 'Text' }); + }); + + await fsa!.open(); + fsa!.data.value = 'edited'; + await fsa!.save(); + + expect(writable.write).toHaveBeenCalledWith('edited'); + expect(writable.close).toHaveBeenCalledOnce(); + scope.stop(); + }); + + it('save falls back to saveAs when there is no handle', async () => { + const file = makeFile(''); + const { handle, writable } = makeHandle(file); + const { window, showSaveFilePicker, showOpenFilePicker } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, dataType: 'Text' }); + }); + + fsa!.data.value = 'fresh'; + await fsa!.save(); + + expect(showOpenFilePicker).not.toHaveBeenCalled(); + expect(showSaveFilePicker).toHaveBeenCalledOnce(); + expect(writable.write).toHaveBeenCalledWith('fresh'); + scope.stop(); + }); + + it('saveAs requests a new handle and writes data', async () => { + const file = makeFile(''); + const { handle, writable } = makeHandle(file); + const { window, showSaveFilePicker } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, dataType: 'Text' }); + }); + + fsa!.data.value = 'payload'; + await fsa!.saveAs({ suggestedName: 'out.txt' }); + + expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'out.txt' }); + expect(writable.write).toHaveBeenCalledWith('payload'); + scope.stop(); + }); + + it('does not write when there is no data', async () => { + const file = makeFile(''); + const { handle, writable } = makeHandle(file); + const { window } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, dataType: 'Text' }); + }); + + await fsa!.saveAs(); + expect(writable.write).not.toHaveBeenCalled(); + scope.stop(); + }); + + it('updateData re-reads the current file', async () => { + const file = makeFile('v1'); + const { handle } = makeHandle(file); + const { window } = stubWindow(handle); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, dataType: 'Text' }); + }); + + await fsa!.open(); + expect(fsa!.data.value).toBe('v1'); + + (file.text as ReturnType).mockResolvedValueOnce('v2'); + await fsa!.updateData(); + expect(fsa!.data.value).toBe('v2'); + scope.stop(); + }); + + it('re-reads data when a reactive dataType changes', async () => { + const file = makeFile('reactive'); + const { handle } = makeHandle(file); + const { window } = stubWindow(handle); + const dataType = ref<'Text' | 'Blob'>('Text'); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, dataType }); + }); + + await fsa!.open(); + expect(fsa!.data.value).toBe('reactive'); + + dataType.value = 'Blob'; + await nextTick(); + await Promise.resolve(); + expect(fsa!.data.value).toBe(file); + scope.stop(); + }); + + it('routes picker errors to onError instead of throwing', async () => { + const abort = new DOMException('cancelled', 'AbortError'); + const window = { + showOpenFilePicker: vi.fn(async () => { throw abort; }), + showSaveFilePicker: vi.fn(async () => { throw abort; }), + } as unknown as Window; + const onError = vi.fn(); + const scope = effectScope(); + let fsa: UseFileSystemAccessReturn; + scope.run(() => { + fsa = useFileSystemAccess({ window, onError }); + }); + + await expect(fsa!.open()).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith(abort); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useFileSystemAccess/index.ts b/vue/toolkit/src/composables/browser/useFileSystemAccess/index.ts new file mode 100644 index 0000000..0655e5c --- /dev/null +++ b/vue/toolkit/src/composables/browser/useFileSystemAccess/index.ts @@ -0,0 +1,332 @@ +import { computed, shallowRef, toValue, watch } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue'; +import { noop } from '@robonen/stdlib'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useSupported } from '@/composables/utilities/useSupported'; + +/** + * `window.showOpenFilePicker` parameters. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#parameters + */ +export interface FileSystemAccessShowOpenFileOptions { + multiple?: boolean; + types?: Array<{ + description?: string; + accept: Record; + }>; + excludeAcceptAllOption?: boolean; +} + +/** + * `window.showSaveFilePicker` parameters. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker#parameters + */ +export interface FileSystemAccessShowSaveFileOptions { + suggestedName?: string; + types?: Array<{ + description?: string; + accept: Record; + }>; + excludeAcceptAllOption?: boolean; +} + +/** + * @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream/write + */ +export interface FileSystemWritableFileStreamWrite { + (data: string | BufferSource | Blob): Promise; + (options: { type: 'write'; position: number; data: string | BufferSource | Blob }): Promise; + (options: { type: 'seek'; position: number }): Promise; + (options: { type: 'truncate'; size: number }): Promise; +} + +/** + * @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream + */ +export interface FileSystemWritableFileStream extends WritableStream { + write: FileSystemWritableFileStreamWrite; + seek: (position: number) => Promise; + truncate: (size: number) => Promise; +} + +/** + * @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle + */ +export interface FileSystemFileHandle { + getFile: () => Promise; + createWritable: () => Promise; +} + +/** + * A `window` augmented with the File System Access API entry points. + */ +export type FileSystemAccessWindow + = Window & { + showSaveFilePicker: (options: FileSystemAccessShowSaveFileOptions) => Promise; + showOpenFilePicker: (options: FileSystemAccessShowOpenFileOptions) => Promise; + }; + +/** + * The supported file data types. + */ +export type UseFileSystemAccessDataType = 'Text' | 'ArrayBuffer' | 'Blob'; + +/** + * Picker options shared between open/create/save operations. + */ +export type UseFileSystemAccessCommonOptions + = Pick; + +/** + * Picker options accepted by save-style operations. + */ +export type UseFileSystemAccessShowSaveFileOptions + = Pick & UseFileSystemAccessCommonOptions; + +export type UseFileSystemAccessOptions + = ConfigurableWindow & UseFileSystemAccessCommonOptions & { + /** + * How the file contents are read into `data`. + * + * @default 'Text' + */ + dataType?: MaybeRefOrGetter; + + /** + * Called when a picker or file operation fails. + * + * User-cancelled pickers reject with an `AbortError`; route them here to + * keep them out of the global unhandled-rejection channel. + * + * @default noop + */ + onError?: (error: unknown) => void; + }; + +export interface UseFileSystemAccessReturn { + /** + * Whether the File System Access API is available. + */ + isSupported: ComputedRef; + + /** + * The current file contents, read according to `dataType`. + */ + data: ShallowRef; + + /** + * The currently bound `File`, or `undefined` when no file is open. + */ + file: ShallowRef; + + /** + * The current file name (empty string when no file is open). + */ + fileName: ComputedRef; + + /** + * The current file MIME type (empty string when no file is open). + */ + fileMIME: ComputedRef; + + /** + * The current file size in bytes (`0` when no file is open). + */ + fileSize: ComputedRef; + + /** + * The current file's last-modified timestamp (`0` when no file is open). + */ + fileLastModified: ComputedRef; + + /** + * Show the open-file picker and load the chosen file. + */ + open: (options?: UseFileSystemAccessCommonOptions) => Promise; + + /** + * Show the save-file picker to create a new, empty file handle. + */ + create: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise; + + /** + * Write `data` back to the current handle (falls back to `saveAs` when none). + */ + save: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise; + + /** + * Show the save-file picker, then write `data` to the chosen handle. + */ + saveAs: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise; + + /** + * Re-read `data` (and metadata) from the current handle. + */ + updateData: () => Promise; +} + +/** + * @name useFileSystemAccess + * @category Browser + * @description Create, read, and write local files via the File System Access API. + * + * @param {UseFileSystemAccessOptions} [options={}] Options including `dataType`, `types`, `excludeAcceptAllOption`, and `onError` + * @returns {UseFileSystemAccessReturn} `isSupported`, `data`, `file`, `fileName`, `fileMIME`, `fileSize`, `fileLastModified`, `open`, `create`, `save`, `saveAs`, `updateData` + * + * @example + * const { isSupported, data, open, save } = useFileSystemAccess({ dataType: 'Text' }); + * await open(); + * data.value += '\nappended'; + * await save(); + * + * @example + * // Read raw bytes + * const { data } = useFileSystemAccess({ dataType: 'ArrayBuffer' }); + * + * @since 0.0.15 + */ +export function useFileSystemAccess(): UseFileSystemAccessReturn; +export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn; +export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'ArrayBuffer' }): UseFileSystemAccessReturn; +export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Blob' }): UseFileSystemAccessReturn; +export function useFileSystemAccess(options: UseFileSystemAccessOptions): UseFileSystemAccessReturn; +export function useFileSystemAccess( + options: UseFileSystemAccessOptions = {}, +): UseFileSystemAccessReturn { + const { + window: win = defaultWindow, + dataType = 'Text', + types, + excludeAcceptAllOption, + onError = noop, + } = options; + + const fsWindow = win as FileSystemAccessWindow | undefined; + const isSupported = useSupported(() => fsWindow && 'showSaveFilePicker' in fsWindow && 'showOpenFilePicker' in fsWindow); + + const fileHandle = shallowRef(); + const data = shallowRef(); + const file = shallowRef(); + + const fileName = computed(() => file.value?.name ?? ''); + const fileMIME = computed(() => file.value?.type ?? ''); + const fileSize = computed(() => file.value?.size ?? 0); + const fileLastModified = computed(() => file.value?.lastModified ?? 0); + + // Resolve the picker defaults once per call rather than spreading the full + // options bag (which carries `window`/`onError`) into the native picker. + function pickerDefaults(): UseFileSystemAccessCommonOptions { + return { types, excludeAcceptAllOption }; + } + + async function updateFile(): Promise { + file.value = await fileHandle.value?.getFile(); + } + + async function updateData(): Promise { + await updateFile(); + + const type = toValue(dataType); + + if (type === 'Text') + data.value = await file.value?.text(); + else if (type === 'ArrayBuffer') + data.value = await file.value?.arrayBuffer(); + else if (type === 'Blob') + data.value = file.value; + } + + async function writeData(): Promise { + if (!fileHandle.value || data.value === undefined || data.value === null) + return; + + const stream = await fileHandle.value.createWritable(); + await stream.write(data.value); + await stream.close(); + } + + async function open(_options: UseFileSystemAccessCommonOptions = {}): Promise { + if (!isSupported.value || !fsWindow) + return; + + try { + const [handle] = await fsWindow.showOpenFilePicker({ ...pickerDefaults(), ..._options }); + if (!handle) + return; + fileHandle.value = handle; + await updateData(); + } + catch (error) { + onError(error); + } + } + + async function create(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise { + if (!isSupported.value || !fsWindow) + return; + + try { + fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options }); + data.value = undefined; + await updateData(); + } + catch (error) { + onError(error); + } + } + + async function saveAs(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise { + if (!isSupported.value || !fsWindow) + return; + + try { + fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options }); + await writeData(); + await updateFile(); + } + catch (error) { + onError(error); + } + } + + async function save(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise { + if (!isSupported.value) + return; + + if (!fileHandle.value) + return saveAs(_options); + + try { + await writeData(); + await updateFile(); + } + catch (error) { + onError(error); + } + } + + // Re-read with the new strategy whenever `dataType` changes; skip the redundant + // initial run since nothing is open yet. + watch(() => toValue(dataType), () => { + if (fileHandle.value) + void updateData(); + }); + + return { + isSupported, + data, + file, + fileName, + fileMIME, + fileSize, + fileLastModified, + open, + create, + save, + saveAs, + updateData, + }; +} diff --git a/vue/toolkit/src/composables/browser/useFullscreen/index.ts b/vue/toolkit/src/composables/browser/useFullscreen/index.ts index 7830f7b..b7dfd8b 100644 --- a/vue/toolkit/src/composables/browser/useFullscreen/index.ts +++ b/vue/toolkit/src/composables/browser/useFullscreen/index.ts @@ -6,7 +6,7 @@ import { defaultDocument } from '@/types'; import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; import { unrefElement } from '@/composables/component/unrefElement'; import { useEventListener } from '@/composables/browser/useEventListener'; -import { useSupported } from '@/composables/browser/useSupported'; +import { useSupported } from '@/composables/utilities/useSupported'; import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; diff --git a/vue/toolkit/src/composables/browser/useImage/index.test.ts b/vue/toolkit/src/composables/browser/useImage/index.test.ts new file mode 100644 index 0000000..712a690 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useImage/index.test.ts @@ -0,0 +1,254 @@ +import { effectScope, nextTick, ref } from 'vue'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useImage } from '.'; + +interface FakeImage { + src: string; + srcset: string; + sizes: string; + alt: string; + className: string; + loading: string; + crossOrigin: string | null; + referrerPolicy: string; + width: number; + height: number; + decoding: string; + fetchPriority: string; + isMap: boolean; + useMap: string; + onload: (() => void) | null; + onerror: ((err: unknown) => void) | null; +} + +let lastImage: FakeImage | undefined; +// Decides whether the next constructed image "loads" or "errors". +let shouldFail = false; + +function createImage(): FakeImage { + const img: FakeImage = { + src: '', + srcset: '', + sizes: '', + alt: '', + className: '', + loading: '', + crossOrigin: null, + referrerPolicy: '', + width: 0, + height: 0, + decoding: '', + fetchPriority: '', + isMap: false, + useMap: '', + onload: null, + onerror: null, + }; + + lastImage = img; + + // Mimic the browser firing load/error asynchronously after src is set. + queueMicrotask(() => { + if (shouldFail) + img.onerror?.(new Error('load-error')); + else + img.onload?.(); + }); + + return img; +} + +function createFakeWindow(): Window { + const Image = function Image(): FakeImage { + return createImage(); + } as unknown as new () => HTMLImageElement; + + return { Image } as unknown as Window; +} + +describe(useImage, () => { + let window: Window; + + beforeEach(() => { + lastImage = undefined; + shouldFail = false; + window = createFakeWindow(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('loads an image and exposes the element as state', async () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useImage({ src: '/cat.png' }, { window }); + }); + + expect(result.isLoading.value).toBeTruthy(); + expect(result.isReady.value).toBeFalsy(); + expect(result.error.value).toBe(null); + + await nextTick(); + await nextTick(); + + expect(result.isLoading.value).toBeFalsy(); + expect(result.isReady.value).toBeTruthy(); + expect(result.error.value).toBe(null); + expect(result.state.value).toBe(lastImage); + expect(lastImage?.src).toBe('/cat.png'); + + scope.stop(); + }); + + it('applies the provided image attributes', async () => { + const scope = effectScope(); + scope.run(() => { + useImage( + { + src: '/cat.png', + srcset: '/cat-2x.png 2x', + sizes: '100vw', + alt: 'a cat', + class: 'rounded', + loading: 'lazy', + crossorigin: 'anonymous', + referrerPolicy: 'no-referrer', + width: 320, + height: 240, + decoding: 'async', + fetchPriority: 'high', + ismap: true, + usemap: '#map', + }, + { window }, + ); + }); + + await nextTick(); + await nextTick(); + + expect(lastImage).toBeDefined(); + expect(lastImage!.src).toBe('/cat.png'); + expect(lastImage!.srcset).toBe('/cat-2x.png 2x'); + expect(lastImage!.sizes).toBe('100vw'); + expect(lastImage!.alt).toBe('a cat'); + expect(lastImage!.className).toBe('rounded'); + expect(lastImage!.loading).toBe('lazy'); + expect(lastImage!.crossOrigin).toBe('anonymous'); + expect(lastImage!.referrerPolicy).toBe('no-referrer'); + expect(lastImage!.width).toBe(320); + expect(lastImage!.height).toBe(240); + expect(lastImage!.decoding).toBe('async'); + expect(lastImage!.fetchPriority).toBe('high'); + expect(lastImage!.isMap).toBeTruthy(); + expect(lastImage!.useMap).toBe('#map'); + + scope.stop(); + }); + + it('captures load errors', async () => { + shouldFail = true; + + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useImage({ src: '/missing.png' }, { window }); + }); + + await nextTick(); + await nextTick(); + + expect(result.isLoading.value).toBeFalsy(); + expect(result.isReady.value).toBeFalsy(); + expect(result.error.value).toBeInstanceOf(Error); + expect(result.state.value).toBe(undefined); + + scope.stop(); + }); + + it('reloads when a reactive source changes', async () => { + const src = ref('/a.png'); + + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useImage(() => ({ src: src.value }), { window }); + }); + + await nextTick(); + await nextTick(); + expect(lastImage?.src).toBe('/a.png'); + expect(result.isReady.value).toBeTruthy(); + + src.value = '/b.png'; + await nextTick(); + + // resetOnExecute clears state and flips loading back on + expect(result.isLoading.value).toBeTruthy(); + expect(result.state.value).toBe(undefined); + + await nextTick(); + await nextTick(); + expect(lastImage?.src).toBe('/b.png'); + expect(result.isReady.value).toBeTruthy(); + + scope.stop(); + }); + + it('does not set up a watcher for a plain options object', async () => { + const watchSpy = vi.fn(); + const scope = effectScope(); + scope.run(() => { + const result = useImage({ src: '/static.png' }, { window }); + // execute is the only thing a reload would call; spy after initial run + result.execute = watchSpy; + }); + + await nextTick(); + await nextTick(); + + expect(watchSpy).not.toHaveBeenCalled(); + + scope.stop(); + }); + + it('does not call execute immediately when immediate is false', async () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useImage({ src: '/cat.png' }, { window, immediate: false }); + }); + + expect(result.isLoading.value).toBeFalsy(); + expect(lastImage).toBeUndefined(); + + await result.execute(); + expect(lastImage?.src).toBe('/cat.png'); + expect(result.isReady.value).toBeTruthy(); + + scope.stop(); + }); + + it('rejects when no Image constructor is available (SSR path)', async () => { + const onError = vi.fn(); + // A window without an Image constructor stands in for a non-DOM environment. + const ssrWindow = {} as unknown as Window; + + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useImage({ src: '/cat.png' }, { window: ssrWindow, onError }); + }); + + await nextTick(); + await nextTick(); + + expect(result.isReady.value).toBeFalsy(); + expect(result.error.value).toBeInstanceOf(Error); + expect(onError).toHaveBeenCalledTimes(1); + + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useImage/index.ts b/vue/toolkit/src/composables/browser/useImage/index.ts new file mode 100644 index 0000000..487c875 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useImage/index.ts @@ -0,0 +1,144 @@ +import { isRef, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter } from 'vue'; +import { isFunction } from '@robonen/stdlib'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useAsyncState } from '@/composables/state/useAsyncState'; +import type { UseAsyncStateOptions, UseAsyncStateReturn } from '@/composables/state/useAsyncState'; + +export interface UseImageOptions { + /** Address of the resource */ + src: string; + /** Images to use in different situations, e.g. high-resolution displays, small monitors, etc. */ + srcset?: string; + /** Image sizes for different page layouts */ + sizes?: string; + /** Image alternative information */ + alt?: string; + /** Image classes */ + class?: string; + /** Image loading strategy */ + loading?: HTMLImageElement['loading']; + /** Image CORS settings */ + crossorigin?: string; + /** Referrer policy for fetch — https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy */ + referrerPolicy?: HTMLImageElement['referrerPolicy']; + /** Image width */ + width?: HTMLImageElement['width']; + /** Image height */ + height?: HTMLImageElement['height']; + /** Image decoding hint — https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#decoding */ + decoding?: HTMLImageElement['decoding']; + /** Relative priority hint for fetching the image */ + fetchPriority?: HTMLImageElement['fetchPriority']; + /** Whether the image is a server-side image map */ + ismap?: HTMLImageElement['isMap']; + /** Partial URL (starting with #) of an image map associated with the element */ + usemap?: HTMLImageElement['useMap']; +} + +export interface UseImageAsyncStateOptions + extends UseAsyncStateOptions, ConfigurableWindow {} + +export type UseImageReturn = UseAsyncStateReturn; + +interface LoadImageContext { + window?: Window; +} + +function loadImage(options: UseImageOptions, ctx: LoadImageContext): Promise { + return new Promise((resolve, reject) => { + // `Image` is a global constructor on `typeof globalThis`, not the `Window` interface. + const ImageCtor = (ctx.window as (Window & typeof globalThis) | undefined)?.Image; + + if (!ImageCtor) { + reject(new Error('useImage: no Image constructor available (are you running on the server?)')); + return; + } + + const img = new ImageCtor(); + const { + src, + srcset, + sizes, + alt, + class: className, + loading, + crossorigin, + referrerPolicy, + width, + height, + decoding, + fetchPriority, + ismap, + usemap, + } = options; + + if (alt !== undefined) img.alt = alt; + if (className !== undefined) img.className = className; + if (loading !== undefined) img.loading = loading; + if (crossorigin !== undefined) img.crossOrigin = crossorigin; + if (referrerPolicy !== undefined) img.referrerPolicy = referrerPolicy; + if (width !== undefined) img.width = width; + if (height !== undefined) img.height = height; + if (decoding !== undefined) img.decoding = decoding; + if (fetchPriority !== undefined) img.fetchPriority = fetchPriority; + if (ismap !== undefined) img.isMap = ismap; + if (usemap !== undefined) img.useMap = usemap; + + // Setting srcset/sizes before src lets the browser pick the right candidate up-front. + if (sizes !== undefined) img.sizes = sizes; + if (srcset !== undefined) img.srcset = srcset; + img.src = src; + + img.onload = () => resolve(img); + img.onerror = reject; + }); +} + +/** + * @name useImage + * @category Browser + * @description Reactively load an image in the browser; await the result to render it or show a fallback. + * + * @param {MaybeRefOrGetter} options Image attributes (as used on the `` tag); pass a ref/getter to reload reactively + * @param {UseImageAsyncStateOptions} [asyncStateOptions={}] `useAsyncState` options (`delay`, `immediate`, `onError`, …) plus a configurable `window` + * @returns {UseImageReturn} `useAsyncState`-shaped `{ isLoading, isReady, error, state, execute, … }` for an `HTMLImageElement` + * + * @example + * const { isLoading, error, state: image } = useImage({ src: '/cat.png' }); + * + * @example + * // Reactive source: reloads whenever `src` changes + * const src = ref('/a.png'); + * const { state } = useImage(() => ({ src: src.value, alt: 'photo' })); + * + * @since 0.0.15 + */ +export function useImage( + options: MaybeRefOrGetter, + asyncStateOptions: UseImageAsyncStateOptions = {}, +): UseImageReturn { + const { window = defaultWindow, ...stateOptions } = asyncStateOptions; + + const state = useAsyncState( + () => loadImage(toValue(options), { window }), + undefined, + { + resetOnExecute: true, + ...stateOptions, + }, + ); + + // A plain (non-ref, non-getter) options object can never change, so we skip + // the watcher entirely — no needless deep traversal on every tick. + if (isRef(options) || isFunction(options)) { + watch( + () => toValue(options), + () => state.execute(stateOptions.delay), + { deep: true }, + ); + } + + return state; +} diff --git a/vue/toolkit/src/composables/browser/useLocalFonts/index.test.ts b/vue/toolkit/src/composables/browser/useLocalFonts/index.test.ts new file mode 100644 index 0000000..5526917 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useLocalFonts/index.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope } from 'vue'; +import { useLocalFonts } from '.'; +import type { FontData, QueryLocalFontsOptions } from '.'; + +function makeFont(overrides: Partial = {}): FontData { + return { + postscriptName: 'Arial-BoldMT', + fullName: 'Arial Bold', + family: 'Arial', + style: 'Bold', + blob: async () => new Blob(), + ...overrides, + }; +} + +/** + * Build a fake `window` exposing `queryLocalFonts`. Passed through options so it + * reaches the import-time-captured `defaultWindow` substitute (see test gotcha). + */ +function createWindow(fonts: FontData[] = [makeFont()]) { + const queryLocalFonts = vi.fn(async (_options?: QueryLocalFontsOptions) => fonts); + const win = { queryLocalFonts } as unknown as Window & typeof globalThis; + + return { window: win, queryLocalFonts }; +} + +describe(useLocalFonts, () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reports supported when queryLocalFonts exists on window', () => { + const scope = effectScope(); + const { window } = createWindow(); + + let result: ReturnType; + scope.run(() => { + result = useLocalFonts({ window }); + }); + + expect(result!.isSupported.value).toBeTruthy(); + scope.stop(); + }); + + it('reports unsupported when the API is absent', () => { + const scope = effectScope(); + const win = {} as unknown as Window & typeof globalThis; + + let result: ReturnType; + scope.run(() => { + result = useLocalFonts({ window: win }); + }); + + expect(result!.isSupported.value).toBeFalsy(); + scope.stop(); + }); + + it('is SSR safe when window is undefined', async () => { + const scope = effectScope(); + + let result: ReturnType; + scope.run(() => { + result = useLocalFonts({ window: undefined as unknown as Window }); + }); + + expect(result!.isSupported.value).toBeFalsy(); + await expect(result!.query()).resolves.toBeUndefined(); + scope.stop(); + }); + + it('defaults fonts to an empty array', () => { + const scope = effectScope(); + const { window } = createWindow(); + + let result: ReturnType; + scope.run(() => { + result = useLocalFonts({ window }); + }); + + expect(result!.fonts.value).toEqual([]); + scope.stop(); + }); + + it('populates fonts and returns the list on a successful query', async () => { + const scope = effectScope(); + const fonts = [makeFont(), makeFont({ postscriptName: 'Times', fullName: 'Times New Roman' })]; + const { window, queryLocalFonts } = createWindow(fonts); + + let result: ReturnType; + scope.run(() => { + result = useLocalFonts({ window }); + }); + + const returned = await result!.query(); + + expect(queryLocalFonts).toHaveBeenCalledTimes(1); + expect(returned).toEqual(fonts); + expect(result!.fonts.value).toEqual(fonts); + scope.stop(); + }); + + it('forwards query options to the native API', async () => { + const scope = effectScope(); + const { window, queryLocalFonts } = createWindow(); + + let result: ReturnType; + scope.run(() => { + result = useLocalFonts({ window }); + }); + + await result!.query({ postscriptNames: ['Arial-BoldMT'] }); + + expect(queryLocalFonts).toHaveBeenCalledWith({ postscriptNames: ['Arial-BoldMT'] }); + scope.stop(); + }); + + it('stores the error and invokes onError when the query rejects', async () => { + const scope = effectScope(); + const error = new DOMException('denied', 'NotAllowedError'); + const queryLocalFonts = vi.fn(async () => { + throw error; + }); + const win = { queryLocalFonts } as unknown as Window & typeof globalThis; + const onError = vi.fn(); + + let result: ReturnType; + scope.run(() => { + result = useLocalFonts({ window: win, onError }); + }); + + await expect(result!.query()).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledWith(error); + expect(result!.error.value).toBe(error); + expect(result!.fonts.value).toEqual([]); + scope.stop(); + }); + + it('returns undefined and does not call the API when unsupported', async () => { + const scope = effectScope(); + const win = {} as unknown as Window & typeof globalThis; + + let result: ReturnType; + scope.run(() => { + result = useLocalFonts({ window: win }); + }); + + await expect(result!.query()).resolves.toBeUndefined(); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useLocalFonts/index.ts b/vue/toolkit/src/composables/browser/useLocalFonts/index.ts new file mode 100644 index 0000000..cc84221 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useLocalFonts/index.ts @@ -0,0 +1,158 @@ +import { shallowRef } from 'vue'; +import type { ComputedRef, ShallowRef } from 'vue'; +import { noop } from '@robonen/stdlib'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useSupported } from '@/composables/utilities/useSupported'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; + +/** + * A single font face exposed by the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/FontData). + */ +export interface FontData { + /** + * The PostScript name of the font (e.g. `"Arial-BoldMT"`) + */ + readonly postscriptName: string; + + /** + * The full, human-readable name of the font (e.g. `"Arial Bold"`) + */ + readonly fullName: string; + + /** + * The font family (e.g. `"Arial"`) + */ + readonly family: string; + + /** + * The font style/subfamily (e.g. `"Bold"`) + */ + readonly style: string; + + /** + * Resolve the raw font file as a `Blob` (the full SFNT binary) + */ + blob: () => Promise; +} + +export interface QueryLocalFontsOptions { + /** + * Restrict the results to the fonts whose PostScript names appear in this list. + */ + postscriptNames?: string[]; +} + +interface WindowWithLocalFonts { + queryLocalFonts: (options?: QueryLocalFontsOptions) => Promise; +} + +export interface UseLocalFontsOptions extends ConfigurableWindow { + /** + * Query the local fonts immediately on mount. The Local Font Access API + * requires the `local-fonts` permission (which may prompt the user), so this + * is disabled by default — call `query()` from a user gesture instead. + * + * @default false + */ + immediate?: boolean; + + /** + * Called when a query rejects (e.g. the permission is denied) instead of + * throwing. The same value is also stored in the returned `error` ref. + * + * @default noop + */ + onError?: (error: unknown) => void; +} + +export interface UseLocalFontsReturn { + /** + * Whether the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) is supported + */ + isSupported: ComputedRef; + + /** + * The fonts returned by the most recent successful `query()` + */ + fonts: ShallowRef; + + /** + * The last error thrown by `query()`, or `null` + */ + error: ShallowRef; + + /** + * Enumerate the locally installed fonts. Resolves with the font list, or + * `undefined` when the API is unsupported. Pass `postscriptNames` to filter. + */ + query: (queryOptions?: QueryLocalFontsOptions) => Promise; +} + +/** + * @name useLocalFonts + * @category Browser + * @description Reactive wrapper around the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) for enumerating the user's locally installed fonts. + * + * @param {UseLocalFontsOptions} [options={}] Options + * @param {boolean} [options.immediate=false] Query immediately on mount (requires the `local-fonts` permission) + * @param {Function} [options.onError=noop] Error callback invoked instead of throwing + * @param {Window} [options.window=defaultWindow] Custom `window` instance + * @returns {UseLocalFontsReturn} `isSupported`, `fonts`, `error`, and `query()` + * + * @example + * const { isSupported, fonts, query } = useLocalFonts(); + * // Call from a click handler so the permission prompt is allowed + * async function pickFonts() { + * await query(); + * console.log(fonts.value.map(font => font.fullName)); + * } + * + * @example + * // Query only specific fonts by PostScript name + * const { fonts, query } = useLocalFonts(); + * await query({ postscriptNames: ['Arial-BoldMT'] }); + * + * @since 0.0.15 + */ +export function useLocalFonts(options: UseLocalFontsOptions = {}): UseLocalFontsReturn { + const { + window = defaultWindow, + immediate = false, + onError = noop, + } = options; + + const isSupported = useSupported(() => !!window && 'queryLocalFonts' in window); + const fonts = shallowRef([]); + const error = shallowRef(null); + + async function query(queryOptions?: QueryLocalFontsOptions): Promise { + if (!isSupported.value || !window) + return undefined; + + error.value = null; + + try { + const result = await (window as unknown as WindowWithLocalFonts).queryLocalFonts(queryOptions); + fonts.value = result; + + return result; + } + catch (err) { + error.value = err; + onError(err); + + return undefined; + } + } + + if (immediate) + tryOnMounted(() => query()); + + return { + isSupported, + fonts, + error, + query, + }; +} diff --git a/vue/toolkit/src/composables/browser/useMediaQuery/index.ts b/vue/toolkit/src/composables/browser/useMediaQuery/index.ts index 1c87599..6c5304f 100644 --- a/vue/toolkit/src/composables/browser/useMediaQuery/index.ts +++ b/vue/toolkit/src/composables/browser/useMediaQuery/index.ts @@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue'; import { isFunction, isNumber } from '@robonen/stdlib'; import { defaultWindow } from '@/types'; import type { ConfigurableWindow } from '@/types'; -import { useSupported } from '@/composables/browser/useSupported'; +import { useSupported } from '@/composables/utilities/useSupported'; import { useEventListener } from '@/composables/browser/useEventListener'; export interface UseMediaQueryOptions extends ConfigurableWindow { diff --git a/vue/toolkit/src/composables/browser/usePermission/index.ts b/vue/toolkit/src/composables/browser/usePermission/index.ts index eda344a..f7dce7f 100644 --- a/vue/toolkit/src/composables/browser/usePermission/index.ts +++ b/vue/toolkit/src/composables/browser/usePermission/index.ts @@ -3,7 +3,7 @@ import type { Ref, ShallowRef } from 'vue'; import { isString } from '@robonen/stdlib'; import { defaultNavigator } from '@/types'; import type { ConfigurableNavigator } from '@/types'; -import { useSupported } from '@/composables/browser/useSupported'; +import { useSupported } from '@/composables/utilities/useSupported'; import { useEventListener } from '@/composables/browser/useEventListener'; /** diff --git a/vue/toolkit/src/composables/browser/usePreferredContrast/index.test.ts b/vue/toolkit/src/composables/browser/usePreferredContrast/index.test.ts new file mode 100644 index 0000000..627e9cd --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredContrast/index.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { usePreferredContrast } from '.'; + +function stubMatchMedia(matching: string): void { + vi.stubGlobal('matchMedia', vi.fn((media: string) => ({ + matches: media.includes(matching), + media, + addEventListener: () => {}, + removeEventListener: () => {}, + }))); +} + +describe(usePreferredContrast, () => { + beforeEach(() => vi.stubGlobal('matchMedia', undefined)); + afterEach(() => vi.unstubAllGlobals()); + + it.each([ + ['more'], + ['less'], + ['custom'], + ['no-preference'], + ] as const)('resolves the "%s" contrast preference', async (preference) => { + stubMatchMedia(`prefers-contrast: ${preference}`); + + const scope = effectScope(); + let contrast: ReturnType; + scope.run(() => { + contrast = usePreferredContrast(); + }); + await nextTick(); + + expect(contrast!.value).toBe(preference); + scope.stop(); + }); + + it('prioritizes "more" over the other preferences', async () => { + // Match everything; the resolution order must win. + vi.stubGlobal('matchMedia', vi.fn((media: string) => ({ + matches: true, + media, + addEventListener: () => {}, + removeEventListener: () => {}, + }))); + + const scope = effectScope(); + let contrast: ReturnType; + scope.run(() => { + contrast = usePreferredContrast(); + }); + await nextTick(); + + expect(contrast!.value).toBe('more'); + scope.stop(); + }); + + it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => { + // matchMedia is undefined (stubbed in beforeEach). + const scope = effectScope(); + let contrast: ReturnType; + scope.run(() => { + contrast = usePreferredContrast(); + }); + await nextTick(); + + expect(contrast!.value).toBe('no-preference'); + scope.stop(); + }); + + it('honors a custom ssrContrast fallback when unsupported', async () => { + const scope = effectScope(); + let contrast: ReturnType; + scope.run(() => { + contrast = usePreferredContrast({ ssrContrast: 'more' }); + }); + await nextTick(); + + expect(contrast!.value).toBe('more'); + scope.stop(); + }); + + it('returns "no-preference" with no window provided', async () => { + const scope = effectScope(); + let contrast: ReturnType; + scope.run(() => { + contrast = usePreferredContrast({ window: undefined }); + }); + await nextTick(); + + expect(contrast!.value).toBe('no-preference'); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/usePreferredContrast/index.ts b/vue/toolkit/src/composables/browser/usePreferredContrast/index.ts new file mode 100644 index 0000000..8254bd0 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredContrast/index.ts @@ -0,0 +1,61 @@ +import { computed } from 'vue'; +import type { ComputedRef } from 'vue'; +import { useMediaQuery } from '@/composables/browser/useMediaQuery'; +import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery'; + +export type ContrastType + = 'more' | 'less' | 'custom' | 'no-preference'; + +export interface UsePreferredContrastOptions extends UseMediaQueryOptions { + /** + * The contrast preference assumed during SSR (and the first client render), + * before `window.matchMedia` is available, to avoid hydration flicker. + * + * @default 'no-preference' + */ + ssrContrast?: ContrastType; +} + +/** + * @name usePreferredContrast + * @category Browser + * @description Reactive `prefers-contrast` media query, resolving to the user's + * preferred contrast level. SSR-safe with an optional SSR fallback value. + * + * @param {UsePreferredContrastOptions} [options={}] Options (custom `window`, `ssrContrast`) + * @returns {ComputedRef} Readonly ref of the preferred contrast: `'more' | 'less' | 'custom' | 'no-preference'` + * + * @example + * const contrast = usePreferredContrast(); + * + * @example + * // Provide an SSR fallback to avoid hydration flicker + * const contrast = usePreferredContrast({ ssrContrast: 'more' }); + * + * @since 0.0.15 + */ +export function usePreferredContrast( + options: UsePreferredContrastOptions = {}, +): ComputedRef { + const { ssrContrast = 'no-preference', ...mediaOptions } = options; + + const isMore = useMediaQuery('(prefers-contrast: more)', mediaOptions); + const isLess = useMediaQuery('(prefers-contrast: less)', mediaOptions); + const isCustom = useMediaQuery('(prefers-contrast: custom)', mediaOptions); + const isNoPreference = useMediaQuery('(prefers-contrast: no-preference)', mediaOptions); + + return computed(() => { + if (isMore.value) + return 'more'; + if (isLess.value) + return 'less'; + if (isCustom.value) + return 'custom'; + // When no `prefers-contrast` query matches we're either on a browser that + // does not report a preference, or rendering on the server. Distinguish the + // explicit `no-preference` match from the unknown/SSR case via the fallback. + if (isNoPreference.value) + return 'no-preference'; + return ssrContrast; + }); +} diff --git a/vue/toolkit/src/composables/browser/usePreferredLanguages/index.test.ts b/vue/toolkit/src/composables/browser/usePreferredLanguages/index.test.ts new file mode 100644 index 0000000..a8cbad8 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredLanguages/index.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope } from 'vue'; +import { usePreferredLanguages } from '.'; +import type * as Types from '@/types'; + +type Listener = (event: Event) => void; + +interface StubWindow { + navigator: { languages: readonly string[] }; + addEventListener: (type: string, cb: Listener, options?: unknown) => void; + removeEventListener: (type: string, cb: Listener) => void; + dispatch: () => void; + listenerCount: () => number; + lastOptions: () => unknown; +} + +/** + * Build a minimal stub `window` whose `navigator.languages` is mutable and that + * records its `languagechange` listeners so we can simulate the browser event. + */ +function makeWindow(initial: string[]): StubWindow { + const listeners = new Set(); + let languages: readonly string[] = initial; + let captured: unknown; + + const stub: StubWindow = { + navigator: { + get languages() { + return languages; + }, + }, + addEventListener(type, cb, options) { + if (type === 'languagechange') { + listeners.add(cb); + captured = options; + } + }, + removeEventListener(type, cb) { + if (type === 'languagechange') listeners.delete(cb); + }, + dispatch() { + for (const cb of listeners) cb(new Event('languagechange')); + }, + listenerCount: () => listeners.size, + lastOptions: () => captured, + }; + + // Allow tests to mutate languages before dispatching. + Object.defineProperty(stub, 'setLanguages', { + value: (next: string[]) => { languages = next; }, + }); + + return stub; +} + +describe(usePreferredLanguages, () => { + it('reads the initial navigator.languages', () => { + const win = makeWindow(['en-US', 'en']); + const scope = effectScope(); + let langs: ReturnType; + scope.run(() => { + langs = usePreferredLanguages({ window: win as unknown as Window }); + }); + + expect(langs!.value).toEqual(['en-US', 'en']); + scope.stop(); + }); + + it('updates on languagechange', () => { + const win = makeWindow(['en']); + const scope = effectScope(); + let langs: ReturnType; + scope.run(() => { + langs = usePreferredLanguages({ window: win as unknown as Window }); + }); + + (win as unknown as { setLanguages: (n: string[]) => void }).setLanguages(['fr-FR', 'fr', 'en']); + win.dispatch(); + + expect(langs!.value).toEqual(['fr-FR', 'fr', 'en']); + scope.stop(); + }); + + it('registers the listener with a passive option', () => { + const win = makeWindow(['en']); + const scope = effectScope(); + scope.run(() => { + usePreferredLanguages({ window: win as unknown as Window }); + }); + + expect(win.listenerCount()).toBe(1); + expect(win.lastOptions()).toEqual({ passive: true }); + scope.stop(); + }); + + it('removes the listener when the scope is disposed', () => { + const win = makeWindow(['en']); + const scope = effectScope(); + scope.run(() => { + usePreferredLanguages({ window: win as unknown as Window }); + }); + + expect(win.listenerCount()).toBe(1); + scope.stop(); + expect(win.listenerCount()).toBe(0); + }); + + it('falls back to ["en"] when no window is available (SSR)', async () => { + // `defaultWindow` is import-time captured, so override it via a module mock + // to simulate a server environment where no window exists. + vi.resetModules(); + vi.doMock('@/types', async () => { + const actual = await vi.importActual('@/types'); + return { ...actual, defaultWindow: undefined }; + }); + + const { usePreferredLanguages: ssrUsePreferredLanguages } = await import('.'); + + const scope = effectScope(); + let langs: ReturnType; + scope.run(() => { + langs = ssrUsePreferredLanguages(); + }); + + expect(langs!.value).toEqual(['en']); + scope.stop(); + + vi.doUnmock('@/types'); + vi.resetModules(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/usePreferredLanguages/index.ts b/vue/toolkit/src/composables/browser/usePreferredLanguages/index.ts new file mode 100644 index 0000000..1e4c584 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredLanguages/index.ts @@ -0,0 +1,43 @@ +import type { ShallowRef } from 'vue'; +import { shallowRef } from 'vue'; +import { defaultWindow } from '@/types'; +import type { ConfigurableWindow } from '@/types'; +import { useEventListener } from '@/composables/browser/useEventListener'; + +/** + * @name usePreferredLanguages + * @category Browser + * @description Reactive `navigator.languages`. Tracks the user's preferred languages and + * updates automatically whenever the browser emits a `languagechange` event. + * + * Falls back to `['en']` during SSR or when no `window` is available, so the returned + * value is always a non-empty array. + * + * @param {ConfigurableWindow} [options={}] Options + * @returns {ShallowRef} Reactive list of the user's preferred languages + * + * @example + * const languages = usePreferredLanguages(); + * // -> ['en-US', 'en', 'fr'] + * + * @example + * // Pass a custom window (e.g. an iframe) + * const languages = usePreferredLanguages({ window: iframe.contentWindow }); + * + * @since 0.0.15 + */ +export function usePreferredLanguages(options: ConfigurableWindow = {}): ShallowRef { + const { window = defaultWindow } = options; + + if (!window) + return shallowRef(['en']); + + const navigator = window.navigator; + const value = shallowRef(navigator.languages); + + useEventListener(window, 'languagechange', () => { + value.value = navigator.languages; + }, { passive: true }); + + return value; +} diff --git a/vue/toolkit/src/composables/browser/usePreferredReducedMotion/index.test.ts b/vue/toolkit/src/composables/browser/usePreferredReducedMotion/index.test.ts new file mode 100644 index 0000000..cb22262 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredReducedMotion/index.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { usePreferredReducedMotion } from '.'; + +describe(usePreferredReducedMotion, () => { + beforeEach(() => vi.stubGlobal('matchMedia', undefined)); + afterEach(() => vi.unstubAllGlobals()); + + it('resolves to "reduce" when the query matches', async () => { + vi.stubGlobal('matchMedia', vi.fn((media: string) => ({ + matches: media.includes('reduce'), + media, + addEventListener: () => {}, + removeEventListener: () => {}, + }))); + + const scope = effectScope(); + let motion: ReturnType; + scope.run(() => { + motion = usePreferredReducedMotion(); + }); + await nextTick(); + + expect(motion!.value).toBe('reduce'); + scope.stop(); + }); + + it('resolves to "no-preference" when the query does not match', async () => { + vi.stubGlobal('matchMedia', vi.fn((media: string) => ({ + matches: false, + media, + addEventListener: () => {}, + removeEventListener: () => {}, + }))); + + const scope = effectScope(); + let motion: ReturnType; + scope.run(() => { + motion = usePreferredReducedMotion(); + }); + await nextTick(); + + expect(motion!.value).toBe('no-preference'); + scope.stop(); + }); + + it('reacts to media query changes', async () => { + const listeners = new Set<(event: { matches: boolean }) => void>(); + let currentMatches = false; + + vi.stubGlobal('matchMedia', vi.fn((media: string) => ({ + get matches() { + return currentMatches; + }, + media, + addEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.add(handler), + removeEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.delete(handler), + }))); + + const scope = effectScope(); + let motion: ReturnType; + scope.run(() => { + motion = usePreferredReducedMotion(); + }); + await nextTick(); + + expect(motion!.value).toBe('no-preference'); + + currentMatches = true; + listeners.forEach(handler => handler({ matches: true })); + await nextTick(); + + expect(motion!.value).toBe('reduce'); + scope.stop(); + }); + + it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => { + // matchMedia is left undefined from beforeEach. + const scope = effectScope(); + let motion: ReturnType; + scope.run(() => { + motion = usePreferredReducedMotion(); + }); + await nextTick(); + + expect(motion!.value).toBe('no-preference'); + scope.stop(); + }); + + it('returns "no-preference" when window is explicitly undefined (SSR)', async () => { + const scope = effectScope(); + let motion: ReturnType; + scope.run(() => { + motion = usePreferredReducedMotion({ window: undefined }); + }); + await nextTick(); + + expect(motion!.value).toBe('no-preference'); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/usePreferredReducedMotion/index.ts b/vue/toolkit/src/composables/browser/usePreferredReducedMotion/index.ts new file mode 100644 index 0000000..e5d7222 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredReducedMotion/index.ts @@ -0,0 +1,41 @@ +import { computed } from 'vue'; +import type { ComputedRef } from 'vue'; +import { useMediaQuery } from '@/composables/browser/useMediaQuery'; +import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery'; + +export type ReducedMotionType + = | 'reduce' + | 'no-preference'; + +export type UsePreferredReducedMotionOptions = UseMediaQueryOptions; + +export type UsePreferredReducedMotionReturn = ComputedRef; + +/** + * @name usePreferredReducedMotion + * @category Browser + * @description Reactive `prefers-reduced-motion` media query, resolving to + * `'reduce'` when the user requests reduced motion and `'no-preference'` + * otherwise. SSR-safe via {@link useMediaQuery}. + * + * @param {UsePreferredReducedMotionOptions} [options={}] Options (custom `window`) + * @returns {UsePreferredReducedMotionReturn} Readonly ref of the current motion preference + * + * @example + * const motion = usePreferredReducedMotion(); + * // motion.value === 'reduce' | 'no-preference' + * + * @example + * watchEffect(() => { + * transitionDuration.value = motion.value === 'reduce' ? 0 : 200; + * }); + * + * @since 0.0.15 + */ +export function usePreferredReducedMotion( + options: UsePreferredReducedMotionOptions = {}, +): UsePreferredReducedMotionReturn { + const isReduced = useMediaQuery('(prefers-reduced-motion: reduce)', options); + + return computed(() => isReduced.value ? 'reduce' : 'no-preference'); +} diff --git a/vue/toolkit/src/composables/browser/usePreferredReducedTransparency/index.test.ts b/vue/toolkit/src/composables/browser/usePreferredReducedTransparency/index.test.ts new file mode 100644 index 0000000..2cbb0b6 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredReducedTransparency/index.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, nextTick } from 'vue'; +import { usePreferredReducedTransparency } from '.'; + +describe(usePreferredReducedTransparency, () => { + beforeEach(() => vi.stubGlobal('matchMedia', undefined)); + afterEach(() => vi.unstubAllGlobals()); + + it('resolves to "reduce" when the media query matches', async () => { + vi.stubGlobal('matchMedia', vi.fn((media: string) => ({ + matches: media.includes('reduce'), + media, + addEventListener: () => {}, + removeEventListener: () => {}, + }))); + + const scope = effectScope(); + let transparency: ReturnType; + scope.run(() => { + transparency = usePreferredReducedTransparency(); + }); + await nextTick(); + + expect(transparency!.value).toBe('reduce'); + scope.stop(); + }); + + it('resolves to "no-preference" when the media query does not match', async () => { + vi.stubGlobal('matchMedia', vi.fn((media: string) => ({ + matches: false, + media, + addEventListener: () => {}, + removeEventListener: () => {}, + }))); + + const scope = effectScope(); + let transparency: ReturnType; + scope.run(() => { + transparency = usePreferredReducedTransparency(); + }); + await nextTick(); + + expect(transparency!.value).toBe('no-preference'); + scope.stop(); + }); + + it('defaults to "no-preference" when matchMedia is unsupported (SSR)', async () => { + const scope = effectScope(); + let transparency: ReturnType; + scope.run(() => { + transparency = usePreferredReducedTransparency({ window: undefined }); + }); + await nextTick(); + + expect(transparency!.value).toBe('no-preference'); + scope.stop(); + }); +}); diff --git a/vue/toolkit/src/composables/browser/usePreferredReducedTransparency/index.ts b/vue/toolkit/src/composables/browser/usePreferredReducedTransparency/index.ts new file mode 100644 index 0000000..0722414 --- /dev/null +++ b/vue/toolkit/src/composables/browser/usePreferredReducedTransparency/index.ts @@ -0,0 +1,30 @@ +import { computed } from 'vue'; +import type { ComputedRef } from 'vue'; +import type { ConfigurableWindow } from '@/types'; +import { useMediaQuery } from '@/composables/browser/useMediaQuery'; + +export type ReducedTransparencyType + = 'reduce' | 'no-preference'; + +/** + * @name usePreferredReducedTransparency + * @category Browser + * @description Reactive `prefers-reduced-transparency` media query, resolving to + * `'reduce'` or `'no-preference'`. SSR-safe (defaults to `'no-preference'`). + * + * @param {ConfigurableWindow} [options={}] Options (custom `window`) + * @returns {ComputedRef} Readonly ref of the user's transparency preference + * + * @example + * const transparency = usePreferredReducedTransparency(); + * // transparency.value === 'reduce' | 'no-preference' + * + * @since 0.0.15 + */ +export function usePreferredReducedTransparency( + options: ConfigurableWindow = {}, +): ComputedRef { + const isReduced = useMediaQuery('(prefers-reduced-transparency: reduce)', options); + + return computed(() => isReduced.value ? 'reduce' : 'no-preference'); +} diff --git a/vue/toolkit/src/composables/browser/useScriptTag/index.test.ts b/vue/toolkit/src/composables/browser/useScriptTag/index.test.ts new file mode 100644 index 0000000..44172a8 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useScriptTag/index.test.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { effectScope } from 'vue'; +import { useScriptTag } from '.'; + +const SRC = 'https://example.com/sdk.js'; + +function flushAll(): void { + // Fire `load` on every script tag currently in the head so awaited promises resolve. + document.head.querySelectorAll('script').forEach((el) => { + el.setAttribute('data-loaded', 'true'); + el.dispatchEvent(new Event('load')); + }); +} + +describe(useScriptTag, () => { + afterEach(() => { + document.head.querySelectorAll('script').forEach(el => el.remove()); + vi.unstubAllGlobals(); + }); + + it('injects a script tag with the configured attributes', async () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, undefined, { + manual: true, + type: 'module', + defer: true, + crossOrigin: 'anonymous', + referrerPolicy: 'no-referrer', + noModule: true, + nonce: 'abc123', + attrs: { 'data-test': 'yes', id: 'my-script' }, + }); + }); + + const promise = result.load(); + const el = document.querySelector(`script[src="${SRC}"]`)!; + expect(el).toBeInstanceOf(HTMLScriptElement); + expect(el.type).toBe('module'); + expect(el.defer).toBeTruthy(); + expect(el.crossOrigin).toBe('anonymous'); + expect(el.referrerPolicy).toBe('no-referrer'); + expect(el.noModule).toBeTruthy(); + expect(el.nonce).toBe('abc123'); + expect(el.getAttribute('data-test')).toBe('yes'); + expect(el.getAttribute('id')).toBe('my-script'); + expect(el.parentElement).toBe(document.head); + + flushAll(); + await promise; + expect(result.scriptTag.value).toBe(el); + + scope.stop(); + }); + + it('defaults to async and text/javascript', async () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, undefined, { manual: true }); + }); + + const promise = result.load(false); + const el = await promise as HTMLScriptElement; + expect(el.async).toBeTruthy(); + expect(el.type).toBe('text/javascript'); + + scope.stop(); + }); + + it('calls onLoaded and resolves with the element when the script loads', async () => { + const onLoaded = vi.fn(); + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, onLoaded, { manual: true }); + }); + + const promise = result.load(); + flushAll(); + const el = await promise; + + expect(onLoaded).toHaveBeenCalledTimes(1); + expect(onLoaded).toHaveBeenCalledWith(el); + expect(result.scriptTag.value).toBe(el); + + scope.stop(); + }); + + it('resolves immediately when waitForScriptLoad is false', async () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, undefined, { manual: true }); + }); + + const el = await result.load(false); + expect(el).toBeInstanceOf(HTMLScriptElement); + expect(result.scriptTag.value).toBe(el); + + scope.stop(); + }); + + it('de-duplicates concurrent load calls into a single promise', () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, undefined, { manual: true }); + }); + + const a = result.load(); + const b = result.load(); + expect(a).toBe(b); + expect(document.querySelectorAll(`script[src="${SRC}"]`)).toHaveLength(1); + + scope.stop(); + }); + + it('reuses an existing already-loaded script tag', async () => { + // Pre-existing, already-loaded tag in the DOM. + const existing = document.createElement('script'); + existing.src = SRC; + existing.setAttribute('data-loaded', 'true'); + document.head.appendChild(existing); + + const onLoaded = vi.fn(); + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, onLoaded, { manual: true }); + }); + + const el = await result.load(); + expect(el).toBe(existing); + expect(result.scriptTag.value).toBe(existing); + // Only the original tag is present (no duplicate appended). + expect(document.querySelectorAll(`script[src="${SRC}"]`)).toHaveLength(1); + // onLoaded should not fire for the short-circuit path. + expect(onLoaded).not.toHaveBeenCalled(); + + scope.stop(); + }); + + it('unload removes the tag and clears the ref', async () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, undefined, { manual: true }); + }); + + await result.load(false); + expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull(); + + result.unload(); + expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull(); + expect(result.scriptTag.value).toBeNull(); + + scope.stop(); + }); + + it('unload resets the load promise so the script can be re-loaded', async () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, undefined, { manual: true }); + }); + + const first = result.load(); + result.unload(); + const second = result.load(); + expect(first).not.toBe(second); + expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull(); + + scope.stop(); + }); + + it('removes the tag on scope dispose (non-manual)', () => { + const scope = effectScope(); + scope.run(() => { + useScriptTag(SRC); + }); + + // Outside a component instance, tryOnMounted runs synchronously -> tag injected. + expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull(); + + scope.stop(); + expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull(); + }); + + it('manual mode does not auto-load on mount', () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, undefined, { manual: true }); + }); + + expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull(); + expect(result.scriptTag.value).toBeNull(); + + scope.stop(); + }); + + it('immediate: false defers loading until load() is called', () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, undefined, { immediate: false }); + }); + + expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull(); + + result.load(false); + expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull(); + + scope.stop(); + }); + + it('rejects when the script errors', async () => { + const scope = effectScope(); + let result!: ReturnType; + scope.run(() => { + result = useScriptTag(SRC, undefined, { manual: true }); + }); + + const promise = result.load(); + const el = document.querySelector(`script[src="${SRC}"]`)!; + el.dispatchEvent(new Event('error')); + + await expect(promise).rejects.toBeInstanceOf(Event); + + scope.stop(); + }); + + it('SSR / unsupported path: resolves false and never throws when document is absent', async () => { + // Note: `defaultDocument` is import-time captured and the destructuring default + // `= defaultDocument` only triggers for `undefined`, so we force a falsy document + // (mirroring a real SSR environment where `defaultDocument` itself is undefined). + const scope = effectScope(); + let result!: ReturnType; + expect(() => { + scope.run(() => { + result = useScriptTag(SRC, undefined, { document: null as unknown as Document, manual: true }); + }); + }).not.toThrow(); + + // eslint-disable-next-line vitest/prefer-to-be-falsy -- assert the exact boolean `false`, not any falsy value + await expect(result.load()).resolves.toBe(false); + expect(() => result.unload()).not.toThrow(); + expect(result.scriptTag.value).toBeNull(); + + scope.stop(); + }); + + it('returns the documented shape', () => { + const result = useScriptTag(SRC, undefined, { manual: true }); + expect(result.scriptTag.value).toBeNull(); + expect(typeof result.load).toBe('function'); + expect(typeof result.unload).toBe('function'); + }); +}); diff --git a/vue/toolkit/src/composables/browser/useScriptTag/index.ts b/vue/toolkit/src/composables/browser/useScriptTag/index.ts new file mode 100644 index 0000000..3781623 --- /dev/null +++ b/vue/toolkit/src/composables/browser/useScriptTag/index.ts @@ -0,0 +1,254 @@ +import { shallowRef, toValue } from 'vue'; +import type { MaybeRefOrGetter, ShallowRef } from 'vue'; +import { noop } from '@robonen/stdlib'; +import { defaultDocument } from '@/types'; +import type { ConfigurableDocument } from '@/types'; +import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; +import { useEventListener } from '@/composables/browser/useEventListener'; + +export type ScriptReferrerPolicy + = | 'no-referrer' + | 'no-referrer-when-downgrade' + | 'origin' + | 'origin-when-cross-origin' + | 'same-origin' + | 'strict-origin' + | 'strict-origin-when-cross-origin' + | 'unsafe-url'; + +export interface UseScriptTagOptions extends ConfigurableDocument { + /** + * Load the script immediately on mount. + * + * @default true + */ + immediate?: boolean; + + /** + * Add the `async` attribute to the script tag. + * + * @default true + */ + async?: boolean; + + /** + * Script `type` attribute. + * + * @default 'text/javascript' + */ + type?: string; + + /** + * Take manual control of the timing of loading and unloading. When `true`, + * the script is neither loaded on mount nor unloaded on scope dispose — call + * `load()` / `unload()` yourself. + * + * @default false + */ + manual?: boolean; + + /** + * CORS setting for the script tag. + */ + crossOrigin?: 'anonymous' | 'use-credentials'; + + /** + * Referrer policy for the script request. + */ + referrerPolicy?: ScriptReferrerPolicy; + + /** + * Add the `nomodule` attribute, so the script is skipped by browsers that + * support ES modules. + */ + noModule?: boolean; + + /** + * Add the `defer` attribute to the script tag. + */ + defer?: boolean; + + /** + * Nonce value for CSP (Content Security Policy). + * + * @default undefined + */ + nonce?: string; + + /** + * Custom attributes applied to the script tag via `setAttribute`. + * + * @default {} + */ + attrs?: Record; +} + +export interface UseScriptTagReturn { + /** + * Reactive reference to the underlying `