From 3c69ca68a2a9ccaaefa29430e8dd40bb112605ac Mon Sep 17 00:00:00 2001 From: pglejzer Date: Sun, 8 Feb 2026 12:52:40 +0100 Subject: [PATCH] update to 1.1.0 --- CHANGELOG.md | 16 ++ README.md | 92 +++++- package.json | 10 +- src/Timepicker/Timepicker.tsx | 18 +- src/Timepicker/__tests__/Timepicker.test.tsx | 71 +++++ .../__tests__/useEventHandlers.test.ts | 271 ++++++++++++++++++ .../__tests__/useTimepickerCallbacks.test.ts | 148 ++++++++++ .../__tests__/useTimepickerInstance.test.ts | 102 +++++++ .../__tests__/useTimepickerOptions.test.ts | 77 +++++ .../__tests__/useTimepickerValue.test.ts | 82 ++++++ src/Timepicker/__tests__/utils.test.ts | 25 ++ src/Timepicker/hooks/useEventHandlers.ts | 65 ++++- .../hooks/useTimepickerCallbacks.ts | 10 +- src/Timepicker/types.ts | 69 ++--- src/docs/src/App.tsx | 69 ++++- src/index.ts | 9 +- src/package.json | 14 +- src/tsconfig.json | 5 +- src/vitest.config.ts | 25 ++ src/vitest.setup.ts | 1 + 20 files changed, 1100 insertions(+), 79 deletions(-) create mode 100644 src/Timepicker/__tests__/Timepicker.test.tsx create mode 100644 src/Timepicker/__tests__/useEventHandlers.test.ts create mode 100644 src/Timepicker/__tests__/useTimepickerCallbacks.test.ts create mode 100644 src/Timepicker/__tests__/useTimepickerInstance.test.ts create mode 100644 src/Timepicker/__tests__/useTimepickerOptions.test.ts create mode 100644 src/Timepicker/__tests__/useTimepickerValue.test.ts create mode 100644 src/Timepicker/__tests__/utils.test.ts create mode 100644 src/vitest.config.ts create mode 100644 src/vitest.setup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cda780d..3724432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.1.0] - 2026-02-08 + +### Added + +- Timezone callback support via onTimezoneChange prop +- Range mode callback support via onRangeConfirm, onRangeSwitch, onRangeValidation props +- Comprehensive test suite with Vitest achieving 100% code coverage across all modules +- Plugin registration guide in README with Timezone and Range examples +- Re-exported TimezoneChangeEventData and TimepickerEventMap types from timepicker-ui + +### Updated + +- timepicker-ui dependency from 4.0.3 to 4.1.2 + +--- + ## [1.0.1] - 2026-01-24 ### Updated diff --git a/README.md b/README.md index bde4639..84ff16e 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ function App() { onCancel={() => console.log("Cancelled")} onUpdate={(data) => console.log("Updated:", data)} onSelectHour={(data) => console.log("Hour selected:", data)} - onSelectMinute={(data) => console.log("Minute selected:", data)} + onSelectMinute=(data) => console.log("Minute selected:", data)} onSelectAM={() => console.log("AM selected")} onSelectPM={() => console.log("PM selected")} onError={(data) => console.log("Error:", data)} @@ -124,6 +124,65 @@ function App() { } ``` +### Using Plugins (Timezone & Range) + +> **Important:** Timezone and Range features are **plugins** that must be manually imported and registered. They are **not included by default** for tree-shaking optimization. + +```tsx +"use client"; + +import React, { useEffect } from "react"; +import { Timepicker, PluginRegistry } from "timepicker-ui-react"; + +function App() { + useEffect(() => { + // Register plugins once when component mounts + const registerPlugins = async () => { + const { TimezonePlugin } = await import("timepicker-ui/plugins/timezone"); + const { RangePlugin } = await import("timepicker-ui/plugins/range"); + + PluginRegistry.register(TimezonePlugin); + PluginRegistry.register(RangePlugin); + }; + + registerPlugins(); + }, []); + + return ( + <> + {/* Timezone example */} + console.log("Timezone changed:", data)} + /> + + {/* Range example */} + console.log("Range confirmed:", data)} + onRangeSwitch={(data) => console.log("Range switch:", data)} + onRangeValidation={(data) => console.log("Range validation:", data)} + /> + + ); +} +``` + +**Why plugins must be registered manually:** + +- **Tree-shaking** - Bundle only what you use +- **Performance** - Avoid loading unnecessary code +- **SSR compatibility** - Plugins load on client-side only + ### SSR (Next.js Example) ```tsx @@ -152,20 +211,23 @@ The component is SSR-safe by default and will render a basic input during server ### `TimepickerProps` -| Prop | Type | Description | -| ---------------- | ------------------------------------ | ------------------------------------------ | -| `options` | `TimepickerOptions` | Full configuration from timepicker-ui core | -| `value` | `string` | Controlled value | -| `defaultValue` | `string` | Default value for uncontrolled usage | -| `onConfirm` | `CallbacksOptions['onConfirm']` | Triggered when user confirms time | -| `onCancel` | `CallbacksOptions['onCancel']` | Triggered when user cancels | -| `onOpen` | `CallbacksOptions['onOpen']` | Triggered when timepicker opens | -| `onUpdate` | `CallbacksOptions['onUpdate']` | Triggered during real-time interaction | -| `onSelectHour` | `CallbacksOptions['onSelectHour']` | Triggered when hour mode is activated | -| `onSelectMinute` | `CallbacksOptions['onSelectMinute']` | Triggered when minute mode is activated | -| `onSelectAM` | `CallbacksOptions['onSelectAM']` | Triggered when AM is selected | -| `onSelectPM` | `CallbacksOptions['onSelectPM']` | Triggered when PM is selected | -| `onError` | `CallbacksOptions['onError']` | Triggered on validation error | +| Prop | Type | Description | +| ------------------- | --------------------------------------- | ------------------------------------------ | +| `options` | `TimepickerOptions` | Full configuration from timepicker-ui core | +| `value` | `string` | Controlled value | +| `defaultValue` | `string` | Default value for uncontrolled usage | +| `onConfirm` | `CallbacksOptions['onConfirm']` | Triggered when user confirms time | +| `onCancel` | `CallbacksOptions['onCancel']` | Triggered when user cancels | +| `onOpen` | `CallbacksOptions['onOpen']` | Triggered when timepicker opens | +| `onUpdate` | `CallbacksOptions['onUpdate']` | Triggered during real-time interaction | +| `onSelectHour` | `CallbacksOptions['onSelectHour']` | Triggered when hour mode is activated | +| `onSelectMinute` | `CallbacksOptions['onSelectMinute']` | Triggered when minute mode is activated | +| `onSelectAM` | `CallbacksOptions['onSelectAM']` | Triggered when AM is selected | +| `onSelectPM` | `CallbacksOptions['onSelectPM']` | Triggered when PM is selected | +| `onTimezoneChange` | `CallbacksOptions['onTimezoneChange']` | Triggered when timezone changes (plugin) | +| `onRangeConfirm` | `CallbacksOptions['onRangeConfirm']` | Triggered when range is confirmed (plugin) | +| `onRangeSwitch` | `CallbacksOptions['onRangeSwitch']` | Triggered when range switches (plugin) | +| `onRangeValidation` | `CallbacksOptions['onRangeValidation']` | Triggered on range validation (plugin) | The component extends `React.InputHTMLAttributes`, so all standard input props can be passed directly: diff --git a/package.json b/package.json index c86f1a2..bfe464c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timepicker-ui-react", - "version": "1.0.1", + "version": "1.1.0", "description": "Official React wrapper for timepicker-ui v4.x", "type": "module", "sideEffects": false, @@ -15,16 +15,16 @@ "files": [ "dist" ], - "scripts": { - "build": "cd src && npm run build" - }, "keywords": [ + "time", "timepicker", "timepicker-ui-react", "timepicker-ui", "time-picker", "react", "react-timepicker", + "material", + "design", "ui", "typescript", "material-design" @@ -44,6 +44,6 @@ "react-dom": ">=17" }, "dependencies": { - "timepicker-ui": "4.0.3" + "timepicker-ui": "4.1.2" } } diff --git a/src/Timepicker/Timepicker.tsx b/src/Timepicker/Timepicker.tsx index e5f5bc3..8707419 100644 --- a/src/Timepicker/Timepicker.tsx +++ b/src/Timepicker/Timepicker.tsx @@ -23,6 +23,10 @@ export const Timepicker = forwardRef( onSelectAM, onSelectPM, onError, + onTimezoneChange, + onRangeConfirm, + onRangeSwitch, + onRangeValidation, onChange, ...inputProps } = props; @@ -32,7 +36,7 @@ export const Timepicker = forwardRef( useImperativeHandle( forwardedRef, () => inputRef.current as HTMLInputElement, - [] + [], ); const reactCallbacks = { @@ -45,11 +49,15 @@ export const Timepicker = forwardRef( onSelectAM, onSelectPM, onError, + onTimezoneChange, + onRangeConfirm, + onRangeSwitch, + onRangeValidation, }; const { attachEventHandlers, detachEventHandlers } = useEventHandlers( reactCallbacks, - options?.callbacks + options?.callbacks, ); const pickerRef = useTimepickerInstance( @@ -58,7 +66,7 @@ export const Timepicker = forwardRef( value, defaultValue, attachEventHandlers, - detachEventHandlers + detachEventHandlers, ); useTimepickerValue(pickerRef, value); @@ -67,7 +75,7 @@ export const Timepicker = forwardRef( pickerRef, attachEventHandlers, detachEventHandlers, - reactCallbacks + reactCallbacks, ); const isControlled = value !== undefined; @@ -80,7 +88,7 @@ export const Timepicker = forwardRef( {...(isControlled ? { value, readOnly: true } : { defaultValue })} /> ); - } + }, ); Timepicker.displayName = "Timepicker"; diff --git a/src/Timepicker/__tests__/Timepicker.test.tsx b/src/Timepicker/__tests__/Timepicker.test.tsx new file mode 100644 index 0000000..e21f481 --- /dev/null +++ b/src/Timepicker/__tests__/Timepicker.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, afterEach } from "vitest"; +import React, { createRef } from "react"; +import { render, screen, cleanup, act } from "@testing-library/react"; +import { Timepicker } from "../Timepicker"; + +afterEach(cleanup); + +describe("Timepicker", () => { + it("renders an input element", async () => { + await act(async () => { + render(); + }); + + const input = screen.getByPlaceholderText("Select time"); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute("type", "text"); + }); + + it("has displayName set", () => { + expect(Timepicker.displayName).toBe("Timepicker"); + }); + + it("forwards ref to the input element", async () => { + const ref = createRef(); + + await act(async () => { + render(); + }); + + expect(ref.current).toBeInstanceOf(HTMLInputElement); + expect(ref.current?.placeholder).toBe("ref-test"); + }); + + it("spreads native input attributes", async () => { + await act(async () => { + render( + , + ); + }); + + const input = screen.getByPlaceholderText("test"); + expect(input).toHaveClass("my-class"); + expect(input).toBeDisabled(); + expect(input).toHaveAttribute("id", "time-input"); + }); + + it("renders as uncontrolled with defaultValue", async () => { + await act(async () => { + render(); + }); + + const input = screen.getByPlaceholderText("uncontrolled"); + expect(input).toHaveAttribute("value", "09:30"); + expect(input).not.toHaveAttribute("readonly"); + }); + + it("renders as controlled with value and readOnly", async () => { + await act(async () => { + render(); + }); + + const input = screen.getByPlaceholderText("controlled"); + expect(input).toHaveAttribute("value", "14:00"); + expect(input).toHaveAttribute("readonly"); + }); +}); diff --git a/src/Timepicker/__tests__/useEventHandlers.test.ts b/src/Timepicker/__tests__/useEventHandlers.test.ts new file mode 100644 index 0000000..d20ca8a --- /dev/null +++ b/src/Timepicker/__tests__/useEventHandlers.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useEventHandlers } from "../hooks/useEventHandlers"; +import type { TimepickerInstance } from "../types"; + +const createMockPicker = (): TimepickerInstance => + ({ + create: vi.fn(), + destroy: vi.fn(), + open: vi.fn(), + close: vi.fn(), + setValue: vi.fn(), + getValue: vi.fn().mockReturnValue("12:00"), + update: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + getWrapper: vi.fn(), + }) as unknown as TimepickerInstance; + +describe("useEventHandlers", () => { + let mockPicker: TimepickerInstance; + + beforeEach(() => { + mockPicker = createMockPicker(); + }); + + it("returns attach and detach functions", () => { + const { result } = renderHook(() => + useEventHandlers({ onConfirm: vi.fn() }, undefined), + ); + + expect(typeof result.current.attachEventHandlers).toBe("function"); + expect(typeof result.current.detachEventHandlers).toBe("function"); + }); + + it("attaches onConfirm handler", () => { + const onConfirm = vi.fn(); + const { result } = renderHook(() => + useEventHandlers({ onConfirm }, undefined), + ); + + result.current.attachEventHandlers(mockPicker); + + expect(mockPicker.on).toHaveBeenCalledWith("confirm", expect.any(Function)); + }); + + it("attaches all event handlers", () => { + const callbacks = { + onConfirm: vi.fn(), + onCancel: vi.fn(), + onOpen: vi.fn(), + onUpdate: vi.fn(), + onSelectHour: vi.fn(), + onSelectMinute: vi.fn(), + onSelectAM: vi.fn(), + onSelectPM: vi.fn(), + onError: vi.fn(), + onTimezoneChange: vi.fn(), + onRangeConfirm: vi.fn(), + onRangeSwitch: vi.fn(), + onRangeValidation: vi.fn(), + }; + + const { result } = renderHook(() => useEventHandlers(callbacks, undefined)); + + result.current.attachEventHandlers(mockPicker); + + const onCalls = (mockPicker.on as ReturnType).mock.calls; + const eventNames = onCalls.map((call) => call[0] as string); + + expect(eventNames).toContain("confirm"); + expect(eventNames).toContain("cancel"); + expect(eventNames).toContain("open"); + expect(eventNames).toContain("update"); + expect(eventNames).toContain("select:hour"); + expect(eventNames).toContain("select:minute"); + expect(eventNames).toContain("select:am"); + expect(eventNames).toContain("select:pm"); + expect(eventNames).toContain("error"); + expect(eventNames).toContain("timezone:change"); + expect(eventNames).toContain("range:confirm"); + expect(eventNames).toContain("range:switch"); + expect(eventNames).toContain("range:validation"); + }); + + it("detaches all event handlers", () => { + const callbacks = { + onConfirm: vi.fn(), + onCancel: vi.fn(), + onOpen: vi.fn(), + onUpdate: vi.fn(), + onSelectHour: vi.fn(), + onSelectMinute: vi.fn(), + onSelectAM: vi.fn(), + onSelectPM: vi.fn(), + onError: vi.fn(), + onTimezoneChange: vi.fn(), + onRangeConfirm: vi.fn(), + onRangeSwitch: vi.fn(), + onRangeValidation: vi.fn(), + }; + + const { result } = renderHook(() => useEventHandlers(callbacks, undefined)); + + result.current.detachEventHandlers(mockPicker); + + const offCalls = (mockPicker.off as ReturnType).mock.calls; + const eventNames = offCalls.map((call) => call[0] as string); + + expect(eventNames).toContain("confirm"); + expect(eventNames).toContain("cancel"); + expect(eventNames).toContain("open"); + expect(eventNames).toContain("update"); + expect(eventNames).toContain("select:hour"); + expect(eventNames).toContain("select:minute"); + expect(eventNames).toContain("select:am"); + expect(eventNames).toContain("select:pm"); + expect(eventNames).toContain("error"); + expect(eventNames).toContain("timezone:change"); + expect(eventNames).toContain("range:confirm"); + expect(eventNames).toContain("range:switch"); + expect(eventNames).toContain("range:validation"); + }); + + it("does not attach handlers when callback is undefined", () => { + const { result } = renderHook(() => useEventHandlers({}, undefined)); + + result.current.attachEventHandlers(mockPicker); + + expect(mockPicker.on).not.toHaveBeenCalled(); + }); + + it("merges react callbacks with options callbacks", () => { + const optsCb = vi.fn(); + const reactCb = vi.fn(); + + const { result } = renderHook(() => + useEventHandlers({ onConfirm: reactCb }, { onConfirm: optsCb }), + ); + + result.current.attachEventHandlers(mockPicker); + + const onCalls = (mockPicker.on as ReturnType).mock.calls; + const confirmCall = onCalls.find((call) => call[0] === "confirm"); + const mergedHandler = confirmCall![1] as ( + data: Record, + ) => void; + + mergedHandler({ hour: "10", minutes: "30" }); + + expect(optsCb).toHaveBeenCalledWith({ hour: "10", minutes: "30" }); + expect(reactCb).toHaveBeenCalledWith({ hour: "10", minutes: "30" }); + }); + + it("options callback fires before react callback", () => { + const order: string[] = []; + const optsCb = vi.fn(() => order.push("opts")); + const reactCb = vi.fn(() => order.push("react")); + + const { result } = renderHook(() => + useEventHandlers({ onCancel: reactCb }, { onCancel: optsCb }), + ); + + result.current.attachEventHandlers(mockPicker); + + const onCalls = (mockPicker.on as ReturnType).mock.calls; + const cancelCall = onCalls.find((call) => call[0] === "cancel"); + const mergedHandler = cancelCall![1] as ( + data: Record, + ) => void; + + mergedHandler({}); + + expect(order).toEqual(["opts", "react"]); + }); + + it("works when only options callback is provided", () => { + const optsCb = vi.fn(); + + const { result } = renderHook(() => + useEventHandlers({}, { onOpen: optsCb }), + ); + + result.current.attachEventHandlers(mockPicker); + + const onCalls = (mockPicker.on as ReturnType).mock.calls; + const openCall = onCalls.find((call) => call[0] === "open"); + const handler = openCall![1] as (data: Record) => void; + + handler({ hour: "12", minutes: "00" }); + + expect(optsCb).toHaveBeenCalled(); + }); + + it("handles all callback types from options", () => { + const optCallbacks = { + onConfirm: vi.fn(), + onCancel: vi.fn(), + onOpen: vi.fn(), + onUpdate: vi.fn(), + onSelectHour: vi.fn(), + onSelectMinute: vi.fn(), + onSelectAM: vi.fn(), + onSelectPM: vi.fn(), + onError: vi.fn(), + onTimezoneChange: vi.fn(), + onRangeConfirm: vi.fn(), + onRangeSwitch: vi.fn(), + onRangeValidation: vi.fn(), + }; + + const { result } = renderHook(() => useEventHandlers({}, optCallbacks)); + + result.current.attachEventHandlers(mockPicker); + + const onCalls = (mockPicker.on as ReturnType).mock.calls; + + onCalls.forEach((call) => { + const handler = call[1] as (data: Record) => void; + handler({}); + }); + + Object.values(optCallbacks).forEach((cb) => { + expect(cb).toHaveBeenCalled(); + }); + }); + + it("handles all react callbacks without options", () => { + const reactCallbacks = { + onConfirm: vi.fn(), + onCancel: vi.fn(), + onOpen: vi.fn(), + onUpdate: vi.fn(), + onSelectHour: vi.fn(), + onSelectMinute: vi.fn(), + onSelectAM: vi.fn(), + onSelectPM: vi.fn(), + onError: vi.fn(), + onTimezoneChange: vi.fn(), + onRangeConfirm: vi.fn(), + onRangeSwitch: vi.fn(), + onRangeValidation: vi.fn(), + }; + + const { result } = renderHook(() => + useEventHandlers(reactCallbacks, undefined), + ); + + result.current.attachEventHandlers(mockPicker); + + const onCalls = (mockPicker.on as ReturnType).mock.calls; + + onCalls.forEach((call) => { + const handler = call[1] as (data: Record) => void; + handler({}); + }); + + Object.values(reactCallbacks).forEach((cb) => { + expect(cb).toHaveBeenCalled(); + }); + }); + + it("does not detach handlers when callback is undefined", () => { + const { result } = renderHook(() => useEventHandlers({}, undefined)); + + result.current.detachEventHandlers(mockPicker); + + expect(mockPicker.off).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Timepicker/__tests__/useTimepickerCallbacks.test.ts b/src/Timepicker/__tests__/useTimepickerCallbacks.test.ts new file mode 100644 index 0000000..b88615f --- /dev/null +++ b/src/Timepicker/__tests__/useTimepickerCallbacks.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useTimepickerCallbacks } from "../hooks/useTimepickerCallbacks"; +import type { TimepickerInstance } from "../types"; + +const createMockPicker = (): TimepickerInstance => + ({ + create: vi.fn(), + destroy: vi.fn(), + open: vi.fn(), + close: vi.fn(), + setValue: vi.fn(), + getValue: vi.fn().mockReturnValue("12:00"), + update: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + getWrapper: vi.fn(), + }) as unknown as TimepickerInstance; + +describe("useTimepickerCallbacks", () => { + let mockPicker: TimepickerInstance; + + beforeEach(() => { + mockPicker = createMockPicker(); + }); + + it("attaches handlers when picker is available", () => { + const pickerRef = { current: mockPicker }; + const attach = vi.fn(); + const detach = vi.fn(); + + renderHook(() => + useTimepickerCallbacks(pickerRef, attach, detach, { onConfirm: vi.fn() }), + ); + + expect(detach).toHaveBeenCalledWith(mockPicker); + expect(attach).toHaveBeenCalledWith(mockPicker); + }); + + it("does not attach handlers when picker is null", () => { + const pickerRef = { current: null }; + const attach = vi.fn(); + const detach = vi.fn(); + + renderHook(() => + useTimepickerCallbacks(pickerRef, attach, detach, { onConfirm: vi.fn() }), + ); + + expect(attach).not.toHaveBeenCalled(); + expect(detach).not.toHaveBeenCalled(); + }); + + it("re-attaches when callbacks change", () => { + const pickerRef = { current: mockPicker }; + const attach = vi.fn(); + const detach = vi.fn(); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + + const { rerender } = renderHook( + ({ callbacks }) => + useTimepickerCallbacks(pickerRef, attach, detach, callbacks), + { initialProps: { callbacks: { onConfirm: cb1 } } }, + ); + + attach.mockClear(); + detach.mockClear(); + + rerender({ callbacks: { onConfirm: cb2 } }); + + expect(detach).toHaveBeenCalled(); + expect(attach).toHaveBeenCalled(); + }); + + it("detaches on unmount", () => { + const pickerRef = { current: mockPicker }; + const attach = vi.fn(); + const detach = vi.fn(); + + const { unmount } = renderHook(() => + useTimepickerCallbacks(pickerRef, attach, detach, { onConfirm: vi.fn() }), + ); + + detach.mockClear(); + + unmount(); + + expect(detach).toHaveBeenCalledWith(mockPicker); + }); + + it("handles all callback types in dependencies", () => { + const pickerRef = { current: mockPicker }; + const attach = vi.fn(); + const detach = vi.fn(); + + const allCallbacks = { + onConfirm: vi.fn(), + onCancel: vi.fn(), + onOpen: vi.fn(), + onUpdate: vi.fn(), + onSelectHour: vi.fn(), + onSelectMinute: vi.fn(), + onSelectAM: vi.fn(), + onSelectPM: vi.fn(), + onError: vi.fn(), + onTimezoneChange: vi.fn(), + onRangeConfirm: vi.fn(), + onRangeSwitch: vi.fn(), + onRangeValidation: vi.fn(), + }; + + const { rerender } = renderHook( + ({ callbacks }) => + useTimepickerCallbacks(pickerRef, attach, detach, callbacks), + { initialProps: { callbacks: allCallbacks } }, + ); + + attach.mockClear(); + detach.mockClear(); + + rerender({ + callbacks: { ...allCallbacks, onCancel: vi.fn() }, + }); + + expect(detach).toHaveBeenCalled(); + expect(attach).toHaveBeenCalled(); + }); + + it("handles unmount when pickerRef.current becomes null", () => { + const pickerRef: { current: TimepickerInstance | null } = { + current: mockPicker, + }; + const attach = vi.fn(); + const detach = vi.fn(); + + const { unmount } = renderHook(() => + useTimepickerCallbacks(pickerRef, attach, detach, { onConfirm: vi.fn() }), + ); + + detach.mockClear(); + pickerRef.current = null; + + unmount(); + + expect(detach).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Timepicker/__tests__/useTimepickerInstance.test.ts b/src/Timepicker/__tests__/useTimepickerInstance.test.ts new file mode 100644 index 0000000..45f24ac --- /dev/null +++ b/src/Timepicker/__tests__/useTimepickerInstance.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, cleanup } from "@testing-library/react"; + +const mockPickerInstance = { + create: vi.fn(), + destroy: vi.fn(), + open: vi.fn(), + close: vi.fn(), + setValue: vi.fn(), + getValue: vi.fn().mockReturnValue("12:00"), + update: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + getWrapper: vi.fn(), +}; + +const MockTimepickerUI = vi.fn().mockImplementation(() => mockPickerInstance); + +vi.mock("timepicker-ui", () => ({ + TimepickerUI: vi.fn().mockImplementation(() => ({ + create: vi.fn(), + destroy: vi.fn(), + open: vi.fn(), + close: vi.fn(), + setValue: vi.fn(), + getValue: vi.fn().mockReturnValue("12:00"), + update: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + getWrapper: vi.fn(), + })), + EventEmitter: vi.fn(), + PluginRegistry: { register: vi.fn() }, +})); + +import { useTimepickerInstance } from "../hooks/useTimepickerInstance"; + +const resetMocks = (): void => { + Object.values(mockPickerInstance).forEach((fn) => { + if (typeof fn === "function" && "mockClear" in fn) { + fn.mockClear(); + } + }); + MockTimepickerUI.mockClear(); +}; + +const createInputRef = (): React.RefObject => { + const input = document.createElement("input"); + return { current: input }; +}; + +beforeEach(() => { + resetMocks(); +}); + +afterEach(cleanup); + +describe("useTimepickerInstance", () => { + it("does not create picker when inputRef.current is null", async () => { + const inputRef: React.RefObject = { current: null }; + const attach = vi.fn(); + const detach = vi.fn(); + + renderHook(() => + useTimepickerInstance( + inputRef, + undefined, + undefined, + undefined, + attach, + detach, + ), + ); + + await act(async () => {}); + + expect(attach).not.toHaveBeenCalled(); + }); + + it("returns a ref object", async () => { + const inputRef = createInputRef(); + const attach = vi.fn(); + const detach = vi.fn(); + + const { result } = renderHook(() => + useTimepickerInstance( + inputRef, + undefined, + undefined, + undefined, + attach, + detach, + ), + ); + + await act(async () => {}); + + expect(result.current).toHaveProperty("current"); + }); +}); diff --git a/src/Timepicker/__tests__/useTimepickerOptions.test.ts b/src/Timepicker/__tests__/useTimepickerOptions.test.ts new file mode 100644 index 0000000..fb0adb0 --- /dev/null +++ b/src/Timepicker/__tests__/useTimepickerOptions.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useTimepickerOptions } from "../hooks/useTimepickerOptions"; +import type { TimepickerInstance } from "../types"; +import type { TimepickerOptions } from "timepicker-ui"; + +const createMockPicker = (): TimepickerInstance => + ({ + create: vi.fn(), + destroy: vi.fn(), + open: vi.fn(), + close: vi.fn(), + setValue: vi.fn(), + getValue: vi.fn().mockReturnValue("12:00"), + update: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + getWrapper: vi.fn(), + }) as unknown as TimepickerInstance; + +describe("useTimepickerOptions", () => { + let mockPicker: TimepickerInstance; + + beforeEach(() => { + mockPicker = createMockPicker(); + }); + + it("calls update when options are provided", () => { + const pickerRef = { current: mockPicker }; + const options: TimepickerOptions = { clock: { type: "24h" } }; + + renderHook(() => useTimepickerOptions(pickerRef, options)); + + expect(mockPicker.update).toHaveBeenCalledWith({ + options, + create: true, + }); + }); + + it("does not call update when options is undefined", () => { + const pickerRef = { current: mockPicker }; + + renderHook(() => useTimepickerOptions(pickerRef, undefined)); + + expect(mockPicker.update).not.toHaveBeenCalled(); + }); + + it("does not call update when picker is null", () => { + const pickerRef = { current: null }; + const options: TimepickerOptions = { clock: { type: "12h" } }; + + renderHook(() => useTimepickerOptions(pickerRef, options)); + + expect(mockPicker.update).not.toHaveBeenCalled(); + }); + + it("calls update again when options change", () => { + const pickerRef = { current: mockPicker }; + const options1: TimepickerOptions = { clock: { type: "12h" } }; + const options2: TimepickerOptions = { clock: { type: "24h" } }; + + const { rerender } = renderHook( + ({ opts }) => useTimepickerOptions(pickerRef, opts), + { initialProps: { opts: options1 as TimepickerOptions | undefined } }, + ); + + (mockPicker.update as ReturnType).mockClear(); + + rerender({ opts: options2 }); + + expect(mockPicker.update).toHaveBeenCalledWith({ + options: options2, + create: true, + }); + }); +}); diff --git a/src/Timepicker/__tests__/useTimepickerValue.test.ts b/src/Timepicker/__tests__/useTimepickerValue.test.ts new file mode 100644 index 0000000..b000e0b --- /dev/null +++ b/src/Timepicker/__tests__/useTimepickerValue.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useTimepickerValue } from "../hooks/useTimepickerValue"; +import type { TimepickerInstance } from "../types"; + +const createMockPicker = (): TimepickerInstance => + ({ + create: vi.fn(), + destroy: vi.fn(), + open: vi.fn(), + close: vi.fn(), + setValue: vi.fn(), + getValue: vi.fn().mockReturnValue("12:00"), + update: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + getWrapper: vi.fn(), + }) as unknown as TimepickerInstance; + +describe("useTimepickerValue", () => { + let mockPicker: TimepickerInstance; + + beforeEach(() => { + mockPicker = createMockPicker(); + }); + + it("calls setValue when value is provided", () => { + const pickerRef = { current: mockPicker }; + + renderHook(() => useTimepickerValue(pickerRef, "12:00")); + + expect(mockPicker.setValue).toHaveBeenCalledWith("12:00", true); + }); + + it("does not call setValue when value is undefined", () => { + const pickerRef = { current: mockPicker }; + + renderHook(() => useTimepickerValue(pickerRef, undefined)); + + expect(mockPicker.setValue).not.toHaveBeenCalled(); + }); + + it("does not call setValue when picker is null", () => { + const pickerRef = { current: null }; + + renderHook(() => useTimepickerValue(pickerRef, "12:00")); + + expect(mockPicker.setValue).not.toHaveBeenCalled(); + }); + + it("does not call setValue when value has not changed", () => { + const pickerRef = { current: mockPicker }; + + const { rerender } = renderHook( + ({ value }) => useTimepickerValue(pickerRef, value), + { initialProps: { value: "10:00" as string | undefined } }, + ); + + expect(mockPicker.setValue).toHaveBeenCalledTimes(1); + (mockPicker.setValue as ReturnType).mockClear(); + + rerender({ value: "10:00" }); + + expect(mockPicker.setValue).not.toHaveBeenCalled(); + }); + + it("calls setValue when value changes", () => { + const pickerRef = { current: mockPicker }; + + const { rerender } = renderHook( + ({ value }) => useTimepickerValue(pickerRef, value), + { initialProps: { value: "10:00" as string | undefined } }, + ); + + (mockPicker.setValue as ReturnType).mockClear(); + + rerender({ value: "11:00" }); + + expect(mockPicker.setValue).toHaveBeenCalledWith("11:00", true); + }); +}); diff --git a/src/Timepicker/__tests__/utils.test.ts b/src/Timepicker/__tests__/utils.test.ts new file mode 100644 index 0000000..17ca50d --- /dev/null +++ b/src/Timepicker/__tests__/utils.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { isSSR } from "../utils"; + +describe("isSSR", () => { + it("returns false in jsdom environment", () => { + expect(isSSR()).toBe(false); + }); + + it("returns true when window is undefined", () => { + const original = globalThis.window; + Object.defineProperty(globalThis, "window", { + value: undefined, + writable: true, + configurable: true, + }); + + expect(isSSR()).toBe(true); + + Object.defineProperty(globalThis, "window", { + value: original, + writable: true, + configurable: true, + }); + }); +}); diff --git a/src/Timepicker/hooks/useEventHandlers.ts b/src/Timepicker/hooks/useEventHandlers.ts index 194d454..b0b6bdd 100644 --- a/src/Timepicker/hooks/useEventHandlers.ts +++ b/src/Timepicker/hooks/useEventHandlers.ts @@ -12,11 +12,15 @@ type ReactCallbacks = { onSelectAM?: TimepickerProps["onSelectAM"]; onSelectPM?: TimepickerProps["onSelectPM"]; onError?: TimepickerProps["onError"]; + onTimezoneChange?: TimepickerProps["onTimezoneChange"]; + onRangeConfirm?: TimepickerProps["onRangeConfirm"]; + onRangeSwitch?: TimepickerProps["onRangeSwitch"]; + onRangeValidation?: TimepickerProps["onRangeValidation"]; }; const mergeCallbacks = ( react: ReactCallbacks, - opts?: CallbacksOptions + opts?: CallbacksOptions, ): CallbacksOptions => ({ onConfirm: react.onConfirm || opts?.onConfirm @@ -89,11 +93,43 @@ const mergeCallbacks = ( react.onError?.(data); } : undefined, + + onTimezoneChange: + react.onTimezoneChange || opts?.onTimezoneChange + ? (data) => { + opts?.onTimezoneChange?.(data); + react.onTimezoneChange?.(data); + } + : undefined, + + onRangeConfirm: + react.onRangeConfirm || opts?.onRangeConfirm + ? (data) => { + opts?.onRangeConfirm?.(data); + react.onRangeConfirm?.(data); + } + : undefined, + + onRangeSwitch: + react.onRangeSwitch || opts?.onRangeSwitch + ? (data) => { + opts?.onRangeSwitch?.(data); + react.onRangeSwitch?.(data); + } + : undefined, + + onRangeValidation: + react.onRangeValidation || opts?.onRangeValidation + ? (data) => { + opts?.onRangeValidation?.(data); + react.onRangeValidation?.(data); + } + : undefined, }); export const useEventHandlers = ( reactCallbacks: ReactCallbacks, - optionsCallbacks?: CallbacksOptions + optionsCallbacks?: CallbacksOptions, ) => { const merged = useMemo( () => mergeCallbacks(reactCallbacks, optionsCallbacks), @@ -108,7 +144,11 @@ export const useEventHandlers = ( reactCallbacks.onSelectAM, reactCallbacks.onSelectPM, reactCallbacks.onError, - ] + reactCallbacks.onTimezoneChange, + reactCallbacks.onRangeConfirm, + reactCallbacks.onRangeSwitch, + reactCallbacks.onRangeValidation, + ], ); const attach = useCallback( @@ -123,8 +163,15 @@ export const useEventHandlers = ( if (merged.onSelectAM) picker.on("select:am", merged.onSelectAM); if (merged.onSelectPM) picker.on("select:pm", merged.onSelectPM); if (merged.onError) picker.on("error", merged.onError); + if (merged.onTimezoneChange) + picker.on("timezone:change", merged.onTimezoneChange); + if (merged.onRangeConfirm) + picker.on("range:confirm", merged.onRangeConfirm); + if (merged.onRangeSwitch) picker.on("range:switch", merged.onRangeSwitch); + if (merged.onRangeValidation) + picker.on("range:validation", merged.onRangeValidation); }, - [merged] + [merged], ); const detach = useCallback( @@ -139,8 +186,16 @@ export const useEventHandlers = ( if (merged.onSelectAM) picker.off("select:am", merged.onSelectAM); if (merged.onSelectPM) picker.off("select:pm", merged.onSelectPM); if (merged.onError) picker.off("error", merged.onError); + if (merged.onTimezoneChange) + picker.off("timezone:change", merged.onTimezoneChange); + if (merged.onRangeConfirm) + picker.off("range:confirm", merged.onRangeConfirm); + if (merged.onRangeSwitch) + picker.off("range:switch", merged.onRangeSwitch); + if (merged.onRangeValidation) + picker.off("range:validation", merged.onRangeValidation); }, - [merged] + [merged], ); return { attachEventHandlers: attach, detachEventHandlers: detach }; diff --git a/src/Timepicker/hooks/useTimepickerCallbacks.ts b/src/Timepicker/hooks/useTimepickerCallbacks.ts index d7aafe5..7c46b44 100644 --- a/src/Timepicker/hooks/useTimepickerCallbacks.ts +++ b/src/Timepicker/hooks/useTimepickerCallbacks.ts @@ -11,13 +11,17 @@ type ReactCallbacks = { onSelectAM?: TimepickerProps["onSelectAM"]; onSelectPM?: TimepickerProps["onSelectPM"]; onError?: TimepickerProps["onError"]; + onTimezoneChange?: TimepickerProps["onTimezoneChange"]; + onRangeConfirm?: TimepickerProps["onRangeConfirm"]; + onRangeSwitch?: TimepickerProps["onRangeSwitch"]; + onRangeValidation?: TimepickerProps["onRangeValidation"]; }; export const useTimepickerCallbacks = ( pickerRef: React.RefObject, attachEventHandlers: (picker: TimepickerInstance) => void, detachEventHandlers: (picker: TimepickerInstance) => void, - reactCallbacks: ReactCallbacks + reactCallbacks: ReactCallbacks, ) => { useEffect(() => { const picker = pickerRef.current; @@ -43,5 +47,9 @@ export const useTimepickerCallbacks = ( reactCallbacks.onSelectAM, reactCallbacks.onSelectPM, reactCallbacks.onError, + reactCallbacks.onTimezoneChange, + reactCallbacks.onRangeConfirm, + reactCallbacks.onRangeSwitch, + reactCallbacks.onRangeValidation, ]); }; diff --git a/src/Timepicker/types.ts b/src/Timepicker/types.ts index 6add98a..1b7732a 100644 --- a/src/Timepicker/types.ts +++ b/src/Timepicker/types.ts @@ -1,69 +1,56 @@ import type { TimepickerOptions, CallbacksOptions } from "timepicker-ui"; -export interface TimepickerProps - extends Omit< - React.InputHTMLAttributes, - "ref" | "value" | "defaultValue" | "onError" - > { - /** - * Full configuration object matching timepicker-ui core options - */ +export interface TimepickerProps extends Omit< + React.InputHTMLAttributes, + "ref" | "value" | "defaultValue" | "onError" +> { + /** Full configuration object matching timepicker-ui core options */ options?: TimepickerOptions; - /** - * Controlled value for the timepicker - */ + /** Controlled value for the timepicker */ value?: string; - /** - * Default value for uncontrolled usage - */ + /** Default value for uncontrolled usage */ defaultValue?: string; - /** - * Callback when user confirms time selection - */ + /** Callback when user confirms time selection */ onConfirm?: CallbacksOptions["onConfirm"]; - /** - * Callback when user cancels time selection - */ + /** Callback when user cancels time selection */ onCancel?: CallbacksOptions["onCancel"]; - /** - * Callback when timepicker opens - */ + /** Callback when timepicker opens */ onOpen?: CallbacksOptions["onOpen"]; - /** - * Callback for real-time updates during interaction - */ + /** Callback for real-time updates during interaction */ onUpdate?: CallbacksOptions["onUpdate"]; - /** - * Callback when hour mode is activated - */ + /** Callback when hour mode is activated */ onSelectHour?: CallbacksOptions["onSelectHour"]; - /** - * Callback when minute mode is activated - */ + /** Callback when minute mode is activated */ onSelectMinute?: CallbacksOptions["onSelectMinute"]; - /** - * Callback when AM is selected - */ + /** Callback when AM is selected */ onSelectAM?: CallbacksOptions["onSelectAM"]; - /** - * Callback when PM is selected - */ + /** Callback when PM is selected */ onSelectPM?: CallbacksOptions["onSelectPM"]; - /** - * Callback when validation error occurs - */ + /** Callback when validation error occurs */ onError?: CallbacksOptions["onError"]; + + /** Callback when timezone is changed */ + onTimezoneChange?: CallbacksOptions["onTimezoneChange"]; + + /** Callback when range time is confirmed */ + onRangeConfirm?: CallbacksOptions["onRangeConfirm"]; + + /** Callback when range input switches between from/to */ + onRangeSwitch?: CallbacksOptions["onRangeSwitch"]; + + /** Callback when range validation occurs */ + onRangeValidation?: CallbacksOptions["onRangeValidation"]; } export type TimepickerInstance = InstanceType< diff --git a/src/docs/src/App.tsx b/src/docs/src/App.tsx index f506be5..1329470 100644 --- a/src/docs/src/App.tsx +++ b/src/docs/src/App.tsx @@ -1,10 +1,32 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Timepicker } from "../../Timepicker"; +import { PluginRegistry } from "timepicker-ui"; import type { TimepickerOptions } from "../../index"; function App() { const [time, setTime] = useState(""); const [controlledTime, setControlledTime] = useState("02:30 AM"); + const [pluginsLoaded, setPluginsLoaded] = useState(false); + + useEffect(() => { + const registerPlugins = async () => { + try { + const { TimezonePlugin } = + await import("timepicker-ui/plugins/timezone"); + const { RangePlugin } = await import("timepicker-ui/plugins/range"); + + PluginRegistry.register(TimezonePlugin); + PluginRegistry.register(RangePlugin); + + setPluginsLoaded(true); + console.log("✅ Plugins registered"); + } catch (error) { + console.error("❌ Failed to load plugins:", error); + } + }; + + registerPlugins(); + }, []); const basicOptions: TimepickerOptions = { ui: { @@ -113,6 +135,51 @@ function App() { onError={(data) => console.log("⚠️ Error:", data)} /> + +
+

Timezone Support

+ {!pluginsLoaded &&

Loading plugins...

} + {pluginsLoaded && ( + console.log("✅ Confirmed with TZ:", data)} + onTimezoneChange={(data) => + console.log("🌍 Timezone changed:", data) + } + /> + )} +
+ +
+

Range Selection

+ {!pluginsLoaded &&

Loading plugins...

} + {pluginsLoaded && ( + + console.log("✅ Range confirmed:", data) + } + onRangeSwitch={(data) => console.log("🔄 Range switch:", data)} + onRangeValidation={(data) => + console.log("✔️ Range validation:", data) + } + /> + )} +