From ca374dae5d067fd0e8885749a99d106b4d9d1e61 Mon Sep 17 00:00:00 2001 From: Cohan Carpentier Date: Mon, 23 Sep 2024 14:31:14 -0400 Subject: [PATCH] fix: added stories --- README.md | 2 +- config/biome.base.jsonc | 4 +- hooks/use-local-storage.ts | 35 +++-- hooks/use-media-query.test.ts | 92 ++++++++++++ hooks/use-media-query.tsx | 2 + hooks/use-mounted.ts | 2 + hooks/use-step.ts | 62 -------- package.json | 2 +- stories/alert.stories.tsx | 43 ++++-- stories/avatar.stories.tsx | 44 +++++- stories/breadcrumb.stories.tsx | 36 +++-- stories/button.stories.tsx | 206 +++++--------------------- stories/card.stories.tsx | 28 +++- stories/checkbox.stories.tsx | 35 +++-- stories/dialog.stories.tsx | 47 ++++++ stories/dropdown-menu.stories.tsx | 89 ++++------- stories/input.stories.tsx | 19 +++ stories/popover.stories.tsx | 55 +++++++ stories/progress.stories.tsx | 7 + stories/radio-group.stories.tsx | 41 +++++ stories/select.stories.tsx | 32 ++++ stories/slider.stories.tsx | 7 +- stories/switch.stories.tsx | 26 ++++ stories/tabs.stories.tsx | 56 +++++++ stories/tooltip.stories.tsx | 49 ++++++ stories/use-local-storage.stories.tsx | 55 +++++++ stories/use-mounted.stories.tsx | 36 +++++ utils/sleep.test.ts | 42 ++++++ 28 files changed, 794 insertions(+), 360 deletions(-) create mode 100644 hooks/use-media-query.test.ts delete mode 100644 hooks/use-step.ts create mode 100644 stories/dialog.stories.tsx create mode 100644 stories/input.stories.tsx create mode 100644 stories/popover.stories.tsx create mode 100644 stories/progress.stories.tsx create mode 100644 stories/radio-group.stories.tsx create mode 100644 stories/select.stories.tsx create mode 100644 stories/switch.stories.tsx create mode 100644 stories/tabs.stories.tsx create mode 100644 stories/tooltip.stories.tsx create mode 100644 stories/use-local-storage.stories.tsx create mode 100644 stories/use-mounted.stories.tsx create mode 100644 utils/sleep.test.ts diff --git a/README.md b/README.md index b89552f..c183171 100644 --- a/README.md +++ b/README.md @@ -18,4 +18,4 @@ https://www.npmjs.com/package/@risc0/ui | Statements | Branches | Functions | Lines | | --------------------------- | ----------------------- | ------------------------- | ----------------- | -| ![Statements](https://img.shields.io/badge/statements-38.65%25-red.svg?style=flat) | ![Branches](https://img.shields.io/badge/branches-74.68%25-red.svg?style=flat) | ![Functions](https://img.shields.io/badge/functions-61.9%25-red.svg?style=flat) | ![Lines](https://img.shields.io/badge/lines-38.65%25-red.svg?style=flat) | +| ![Statements](https://img.shields.io/badge/statements-40.8%25-red.svg?style=flat) | ![Branches](https://img.shields.io/badge/branches-78.49%25-red.svg?style=flat) | ![Functions](https://img.shields.io/badge/functions-69.81%25-red.svg?style=flat) | ![Lines](https://img.shields.io/badge/lines-40.8%25-red.svg?style=flat) | diff --git a/config/biome.base.jsonc b/config/biome.base.jsonc index f539189..dba092c 100644 --- a/config/biome.base.jsonc +++ b/config/biome.base.jsonc @@ -56,7 +56,7 @@ "useSingleCaseStatement": "error", "useNumberNamespace": "error", "useFilenamingConvention": { - "level": "warn", + "level": "info", "options": { "strictCase": true, "requireAscii": true, @@ -66,7 +66,7 @@ }, "nursery": { "useSortedClasses": { - "level": "warn", + "level": "info", "options": { "attributes": ["classList"], "functions": ["clsx", "cva", "tw", "cn"] diff --git a/hooks/use-local-storage.ts b/hooks/use-local-storage.ts index c40de4d..69dd425 100644 --- a/hooks/use-local-storage.ts +++ b/hooks/use-local-storage.ts @@ -1,9 +1,13 @@ +"use client"; + import { isEqual, isFunction } from "radash"; import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react"; import { parseJson } from "../utils/parse-json"; import { useEventListener } from "./use-event-listener"; -const isNil = (val: unknown) => val == null; +function isNil(val: unknown) { + return val == null; +} export type SetValue = Dispatch>; @@ -14,7 +18,7 @@ export type SetValue = Dispatch>; * @param key the key to use to store * @returns the value requested */ -const readValueFromStorage = (key: string) => { +function readValueFromStorage(key: string) { try { const item = window.localStorage.getItem(key); const value = item && (parseJson(item) as T); @@ -25,20 +29,22 @@ const readValueFromStorage = (key: string) => { return { error: "unable to read value" }; } -}; +} type StorageError = { error: string; }; -const isError = (value: StorageError | any): value is StorageError => !!value?.error; +function isError(value: StorageError | any): value is StorageError { + return !!value?.error; +} -export const useLocalStorage = (key: string, initialValue: T): [T, SetValue] => { +export function useLocalStorage(key: string, initialValue: T): [T, SetValue] { const previousValueRef = useRef(initialValue); // Get from local storage then // parse stored json or return initialValue - const readValue = (): T => { + function readValue(): T { // Prevent build error "window is undefined" but keep keep working if (typeof window === "undefined") { return initialValue; @@ -47,15 +53,14 @@ export const useLocalStorage = (key: string, initialValue: T): [T, SetValue(key); return isError(value) || isNil(value) || value === "" ? initialValue : (value as T); - }; + } // State to store our value // Pass initial state function to useState so logic is only executed once const [storedValue, setStoredValue] = useState(readValue); - // Return a wrapped version of useState's setter function that ... - // ... persists the new value to localStorage. - const setValue: SetValue = (value) => { + // Return a wrapped version of useState's setter function that persists the new value to localStorage. + function setValue(value: SetStateAction): void { // Prevent build error "window is undefined" but keeps working if (typeof window === "undefined") { console.warn(`Tried setting localStorage key "${key}" even though environment is not a client`); @@ -76,17 +81,16 @@ export const useLocalStorage = (key: string, initialValue: T): [T, SetValue { const newValue = readValue(); setStoredValue(newValue); previousValueRef.current = newValue; - // run only once }, []); - const handleStorageChange = () => { + function handleStorageChange() { const newValue = readValue(); if (isEqual(newValue, previousValueRef.current)) { @@ -94,8 +98,7 @@ export const useLocalStorage = (key: string, initialValue: T): [T, SetValue(key: string, initialValue: T): [T, SetValue { + let matchMediaMock: Mock; + + beforeEach(() => { + matchMediaMock = vi.fn(); + window.matchMedia = matchMediaMock; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function createMatchMedia(matches: boolean) { + return () => ({ + matches, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + } + + it("should return initial value based on media query", () => { + matchMediaMock.mockImplementation(createMatchMedia(true)); + + const { result } = renderHook(() => useMediaQuery("(min-width: 768px)")); + + expect(result.current).toBe(true); + }); + + it("should update value when media query changes", () => { + const listeners: ((event: MediaQueryListEvent) => void)[] = []; + matchMediaMock.mockImplementation(() => ({ + matches: false, + addEventListener: (_, listener) => listeners.push(listener), + removeEventListener: (_, listener) => { + const index = listeners.indexOf(listener); + if (index > -1) { + listeners.splice(index, 1); + } + }, + })); + + const { result } = renderHook(() => useMediaQuery("(min-width: 768px)")); + + expect(result.current).toBe(false); + act(() => { + for (const listener of listeners) { + listener({ matches: true } as MediaQueryListEvent); + } + }); + + expect(result.current).toBe(true); + }); + + it("should remove event listener on unmount", () => { + const removeEventListenerMock = vi.fn(); + matchMediaMock.mockImplementation(() => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: removeEventListenerMock, + })); + + const { unmount } = renderHook(() => useMediaQuery("(min-width: 768px)")); + + unmount(); + + expect(removeEventListenerMock).toHaveBeenCalledWith("change", expect.any(Function)); + }); + + it("should update when query changes", () => { + let currentQuery = "(min-width: 768px)"; + matchMediaMock.mockImplementation(() => ({ + matches: currentQuery === "(min-width: 768px)", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + + const { result, rerender } = renderHook(({ query }) => useMediaQuery(query), { + initialProps: { query: currentQuery }, + }); + + expect(result.current).toBe(true); + + currentQuery = "(max-width: 480px)"; + rerender({ query: currentQuery }); + + expect(result.current).toBe(false); + }); +}); diff --git a/hooks/use-media-query.tsx b/hooks/use-media-query.tsx index 5cc3992..234dbef 100644 --- a/hooks/use-media-query.tsx +++ b/hooks/use-media-query.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useState } from "react"; export function useMediaQuery(query: string) { diff --git a/hooks/use-mounted.ts b/hooks/use-mounted.ts index 90989be..2cde8a7 100644 --- a/hooks/use-mounted.ts +++ b/hooks/use-mounted.ts @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useState } from "react"; export function useMounted() { diff --git a/hooks/use-step.ts b/hooks/use-step.ts deleted file mode 100644 index 07b0bde..0000000 --- a/hooks/use-step.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type Dispatch, type SetStateAction, useCallback, useState } from "react"; - -type UseStepActions = { - goToNextStep: () => void; - goToPrevStep: () => void; - reset: () => void; - canGoToNextStep: boolean; - canGoToPrevStep: boolean; - setStep: Dispatch>; -}; - -type SetStepCallbackType = (step: number | ((step: number) => number)) => void; - -export function useStep(maxStep: number): [number, UseStepActions] { - const [currentStep, setCurrentStep] = useState(1); - - const canGoToNextStep = currentStep + 1 <= maxStep; - const canGoToPrevStep = currentStep - 1 > 0; - - const setStep = useCallback( - (step) => { - // Allow value to be a function so we have the same API as useState - const newStep = step instanceof Function ? step(currentStep) : step; - - if (newStep >= 1 && newStep <= maxStep) { - setCurrentStep(newStep); - return; - } - - throw new Error("Step not valid"); - }, - [maxStep, currentStep], - ); - - const goToNextStep = useCallback(() => { - if (canGoToNextStep) { - setCurrentStep((step) => step + 1); - } - }, [canGoToNextStep]); - - const goToPrevStep = useCallback(() => { - if (canGoToPrevStep) { - setCurrentStep((step) => step - 1); - } - }, [canGoToPrevStep]); - - const reset = useCallback(() => { - setCurrentStep(1); - }, []); - - return [ - currentStep, - { - goToNextStep, - goToPrevStep, - canGoToNextStep, - canGoToPrevStep, - setStep, - reset, - }, - ]; -} diff --git a/package.json b/package.json index 0ec288b..9e189e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@risc0/ui", - "version": "0.0.171", + "version": "0.0.172", "private": false, "sideEffects": false, "type": "module", diff --git a/stories/alert.stories.tsx b/stories/alert.stories.tsx index fe15744..c8205c1 100644 --- a/stories/alert.stories.tsx +++ b/stories/alert.stories.tsx @@ -1,19 +1,32 @@ /* c8 ignore start */ -import { Alert, AlertDescription, AlertTitle } from "alert"; import { RocketIcon } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "../alert"; -export const Default = () => ( - - - Heads up! - You can add components to your app using the cli. - -); +export function Default() { + return ( + + + Heads up! + You can add components to your app using the cli. + + ); +} -export const Destructive = () => ( - - - Heads up! - You can add components to your app using the cli. - -); +export function Destructive() { + return ( + + + Error + Your session has expired. Please log in again. + + ); +} + +export function WithoutIcon() { + return ( + + Note + This is a simple alert without an icon. + + ); +} diff --git a/stories/avatar.stories.tsx b/stories/avatar.stories.tsx index 2ab0695..d040ad0 100644 --- a/stories/avatar.stories.tsx +++ b/stories/avatar.stories.tsx @@ -1,9 +1,39 @@ /* c8 ignore start */ -import { Avatar, AvatarFallback, AvatarImage } from "avatar"; +import React from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "../avatar"; -export const Default = () => ( - - - CN - -); +export function Default() { + return ( + + + CN + + ); +} + +export function Fallback() { + return ( + + JD + + ); +} + +export function Sizes() { + return ( +
+ + + CN + + + + CN + + + + CN + +
+ ); +} diff --git a/stories/breadcrumb.stories.tsx b/stories/breadcrumb.stories.tsx index 687f1f9..f434258 100644 --- a/stories/breadcrumb.stories.tsx +++ b/stories/breadcrumb.stories.tsx @@ -7,15 +7,35 @@ import { BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, -} from "breadcrumb"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "dropdown-menu"; +} from "../breadcrumb"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../dropdown-menu"; -export const Default = () => { +export function Default() { return ( - Home + Home + + + + Docs + + + + Breadcrumb + + + + ); +} + +export function WithEllipsis() { + return ( + + + + Home @@ -33,13 +53,9 @@ export const Default = () => { - Components - - - - Breadcrumb + Current Page ); -}; +} diff --git a/stories/button.stories.tsx b/stories/button.stories.tsx index fc9a512..ab45e9b 100644 --- a/stories/button.stories.tsx +++ b/stories/button.stories.tsx @@ -1,180 +1,46 @@ -/* c8 ignore start */ -import { Button, type ButtonProps } from "button"; -import { Label } from "label"; import { RocketIcon } from "lucide-react"; -import { RadioGroup, RadioGroupItem } from "radio-group"; -import { useState } from "react"; +/* c8 ignore start */ +import { Button } from "../button"; -export const All = () => { - const [variant, setVariant] = useState("default"); +export function Default() { + return ; +} +export function Variants() { return ( - <> - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- - - - - - - - - - - -
- -
- - - - - - - - - - - -
- -
- - - - - - - - - - - -
-
- +
+ + + + + +
); -}; - -export const IconButtons = () => { - const [variant, setVariant] = useState("default"); +} +export function Sizes() { return ( - <> - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
- + + +
+ ); +} - + +
+ ); +} - ; +} - ; +} diff --git a/stories/card.stories.tsx b/stories/card.stories.tsx index 15a4369..24c08e1 100644 --- a/stories/card.stories.tsx +++ b/stories/card.stories.tsx @@ -1,11 +1,11 @@ /* c8 ignore start */ -import { Button } from "button"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "card"; -import { Input } from "input"; -import { Label } from "label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "select"; +import { Button } from "../button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../card"; +import { Input } from "../input"; +import { Label } from "../label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../select"; -export const Default = () => { +export function Default() { return ( @@ -42,4 +42,18 @@ export const Default = () => { ); -}; +} + +export function Simple() { + return ( + + + Card Title + Card Description + + +

Card Content

+
+
+ ); +} diff --git a/stories/checkbox.stories.tsx b/stories/checkbox.stories.tsx index 3185764..82a6bd1 100644 --- a/stories/checkbox.stories.tsx +++ b/stories/checkbox.stories.tsx @@ -1,16 +1,33 @@ /* c8 ignore start */ -import { Checkbox } from "checkbox"; +import { Checkbox } from "../checkbox"; +import { Label } from "../label"; -export const Default = () => { +export function Default() { return (
- +
); -}; +} + +export function Disabled() { + return ( +
+ + +
+ ); +} + +export function WithDescription() { + return ( +
+ +
+ +

You agree to our Terms of Service and Privacy Policy.

+
+
+ ); +} diff --git a/stories/dialog.stories.tsx b/stories/dialog.stories.tsx new file mode 100644 index 0000000..e000eea --- /dev/null +++ b/stories/dialog.stories.tsx @@ -0,0 +1,47 @@ +/* c8 ignore start */ +import React from "react"; +import { Button } from "../button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../dialog"; +import { Input } from "../input"; +import { Label } from "../label"; + +export function Default() { + return ( + + + + + + + Edit profile + Make changes to your profile here. Click save when you're done. + +
+
+ + +
+
+ + +
+
+ + + +
+
+ ); +} diff --git a/stories/dropdown-menu.stories.tsx b/stories/dropdown-menu.stories.tsx index 9014aa6..bb3975d 100644 --- a/stories/dropdown-menu.stories.tsx +++ b/stories/dropdown-menu.stories.tsx @@ -1,76 +1,51 @@ /* c8 ignore start */ -import { Button } from "button"; +import { Button } from "../button"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuPortal, DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "dropdown-menu"; +} from "../dropdown-menu"; -export const Default = () => { +export function Default() { return ( - + - + My Account - - - Profile - ⇧⌘P - - - Billing - ⌘B - - - Settings - ⌘S - - - Keyboard shortcuts - ⌘K - - - - - Team - - Invite users - - - Email - Message - - More... - - - - - New Team - ⌘+T - - - - GitHub - Support - API + Profile + Billing + Team + Subscription + + + ); +} + +export function WithSubMenu() { + return ( + + + + + + New Tab + New Window - - Log out - ⇧⌘Q - + Share + + More Options + + Reload + Force Reload + + ); -}; +} diff --git a/stories/input.stories.tsx b/stories/input.stories.tsx new file mode 100644 index 0000000..c39f54e --- /dev/null +++ b/stories/input.stories.tsx @@ -0,0 +1,19 @@ +/* c8 ignore start */ +import { Input } from "../input"; + +export function Default() { + return ; +} + +export function Disabled() { + return ; +} + +export function WithLabel() { + return ( +
+ + +
+ ); +} diff --git a/stories/popover.stories.tsx b/stories/popover.stories.tsx new file mode 100644 index 0000000..f2cafce --- /dev/null +++ b/stories/popover.stories.tsx @@ -0,0 +1,55 @@ +/* c8 ignore start */ +import React from "react"; +import { Button } from "../button"; +import { Popover, PopoverContent, PopoverTrigger } from "../popover"; + +export function Default() { + return ( + + + + + +
+
+

Dimensions

+

Set the dimensions for the layer.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ ); +} + +export function Placement() { + return ( +
+ + + + + +

This popover opens on the top side.

+
+
+
+ ); +} diff --git a/stories/progress.stories.tsx b/stories/progress.stories.tsx new file mode 100644 index 0000000..152bf1f --- /dev/null +++ b/stories/progress.stories.tsx @@ -0,0 +1,7 @@ +/* c8 ignore start */ +import React from "react"; +import { Progress } from "../progress"; + +export function Default() { + return ; +} diff --git a/stories/radio-group.stories.tsx b/stories/radio-group.stories.tsx new file mode 100644 index 0000000..752e15b --- /dev/null +++ b/stories/radio-group.stories.tsx @@ -0,0 +1,41 @@ +/* c8 ignore start */ +import { Label } from "../label"; +import { RadioGroup, RadioGroupItem } from "../radio-group"; + +export function Default() { + return ( + +
+ + +
+
+ + +
+
+ + +
+
+ ); +} + +export function Disabled() { + return ( + +
+ + +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/stories/select.stories.tsx b/stories/select.stories.tsx new file mode 100644 index 0000000..14ab089 --- /dev/null +++ b/stories/select.stories.tsx @@ -0,0 +1,32 @@ +/* c8 ignore start */ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../select"; + +export function Default() { + return ( + + ); +} + +export function Disabled() { + return ( + + ); +} diff --git a/stories/slider.stories.tsx b/stories/slider.stories.tsx index fa2587c..c94da8e 100644 --- a/stories/slider.stories.tsx +++ b/stories/slider.stories.tsx @@ -1,6 +1,7 @@ /* c8 ignore start */ -import { Slider } from "slider"; +import React from "react"; +import { Slider } from "../slider"; -export const Default = () => { +export function Default() { return ; -}; +} diff --git a/stories/switch.stories.tsx b/stories/switch.stories.tsx new file mode 100644 index 0000000..58f08b0 --- /dev/null +++ b/stories/switch.stories.tsx @@ -0,0 +1,26 @@ +/* c8 ignore start */ +import React from "react"; +import { Label } from "../label"; +import { Switch } from "../switch"; + +export function Default() { + return ; +} + +export function WithLabel() { + return ( +
+ + +
+ ); +} + +export function Disabled() { + return ( +
+ + +
+ ); +} diff --git a/stories/tabs.stories.tsx b/stories/tabs.stories.tsx new file mode 100644 index 0000000..6c14a3c --- /dev/null +++ b/stories/tabs.stories.tsx @@ -0,0 +1,56 @@ +/* c8 ignore start */ +import React from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs"; + +export function Default() { + return ( + + + Account + Password + + Make changes to your account here. + Change your password here. + + ); +} + +export function WithContent() { + return ( + + + Account + Password + + +

Make changes to your account here.

+
+ + +
+
+ +

Change your password here.

+
+ + +
+
+
+ ); +} diff --git a/stories/tooltip.stories.tsx b/stories/tooltip.stories.tsx new file mode 100644 index 0000000..0578667 --- /dev/null +++ b/stories/tooltip.stories.tsx @@ -0,0 +1,49 @@ +/* c8 ignore start */ +import React from "react"; +import { Button } from "../button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../tooltip"; + +export function Default() { + return ( + + + + + + +

This is a tooltip

+
+
+
+ ); +} + +export function WithDelay() { + return ( + + + + + + +

This tooltip appears after a delay

+
+
+
+ ); +} + +export function CustomPosition() { + return ( + + + + + + +

This tooltip appears on top

+
+
+
+ ); +} diff --git a/stories/use-local-storage.stories.tsx b/stories/use-local-storage.stories.tsx new file mode 100644 index 0000000..2f93e51 --- /dev/null +++ b/stories/use-local-storage.stories.tsx @@ -0,0 +1,55 @@ +import { Button } from "button"; +/* c8 ignore start */ +import { useLocalStorage } from "../hooks/use-local-storage"; +import { Input } from "../input"; +import { Label } from "../label"; + +export function Default() { + const [value, setValue] = useLocalStorage("example-key", "initial value"); + + return ( +
+

useLocalStorage Hook

+

Current value: {value}

+
+ + +
+
+ ); +} + +export function MultipleValues() { + const [name, setName] = useLocalStorage("user-name", ""); + const [age, setAge] = useLocalStorage("user-age", 0); + + const clearAll = () => { + setName(""); + setAge(0); + }; + + return ( +
+

useLocalStorage Hook - Multiple Values

+
+
+ + setName(e.target.value)} /> +
+
+ + setAge(Number(e.target.value))} /> +
+
+
+

Stored Name: {name}

+

Stored Age: {age}

+
+ +
+ ); +} diff --git a/stories/use-mounted.stories.tsx b/stories/use-mounted.stories.tsx new file mode 100644 index 0000000..0fcdcc6 --- /dev/null +++ b/stories/use-mounted.stories.tsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +/* c8 ignore start */ +import { useMounted } from "../hooks/use-mounted"; + +export function Default() { + const isMounted = useMounted(); + + return ( +
+

useMounted Hook

+

Is component mounted: {isMounted ? "Yes" : "No"}

+
+ ); +} + +export function DelayedMount() { + const [isDelayedMounted, setIsDelayedMounted] = useState(false); + const isMounted = useMounted(); + + useEffect(() => { + if (isMounted) { + const timer = setTimeout(() => { + setIsDelayedMounted(true); + }, 2000); // 2 seconds delay + + return () => clearTimeout(timer); + } + }, [isMounted]); + + return ( +
+

useMounted Hook with Delay

+ {isDelayedMounted ?

Component is now mounted after delay!

:

Component is not fully mounted yet...

} +
+ ); +} diff --git a/utils/sleep.test.ts b/utils/sleep.test.ts new file mode 100644 index 0000000..3031dd3 --- /dev/null +++ b/utils/sleep.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sleep } from "./sleep"; + +describe("sleep", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should resolve after the specified time", async () => { + const ms = 1000; + const promise = sleep(ms); + + vi.advanceTimersByTime(ms); + + await expect(promise).resolves.toBeUndefined(); + }); + + it("should not resolve before the specified time", async () => { + const ms = 2000; + const promise = sleep(ms); + + vi.advanceTimersByTime(ms - 1); + + const immediateResult = await Promise.race([promise, Promise.resolve("not resolved")]); + + expect(immediateResult).toBe("not resolved"); + }); + + it("should work with different time values", async () => { + const testCases = [0, 100, 500, 1000]; + + for (const ms of testCases) { + const promise = sleep(ms); + vi.advanceTimersByTime(ms); + await expect(promise).resolves.toBeUndefined(); + } + }); +});