preact-missing-hooks 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ /** @jsx h */
2
+ import { h } from 'preact'
3
+ import { useState } from 'preact/hooks'
4
+ import { render, fireEvent, waitFor } from '@testing-library/preact'
5
+ import { useClipboard } from '../src/useClipboard'
6
+
7
+ describe('useClipboard', () => {
8
+ const originalNavigator = global.navigator
9
+
10
+ afterEach(() => {
11
+ Object.defineProperty(global, 'navigator', {
12
+ value: originalNavigator,
13
+ writable: true,
14
+ })
15
+ vi.useRealTimers()
16
+ })
17
+
18
+ it('copy succeeds and sets copied to true', async () => {
19
+ vi.useFakeTimers()
20
+ const writeText = vi.fn().mockResolvedValue(undefined)
21
+ Object.defineProperty(global, 'navigator', {
22
+ value: { ...originalNavigator, clipboard: { writeText } },
23
+ writable: true,
24
+ })
25
+
26
+ function TestComponent() {
27
+ const { copy, copied } = useClipboard({ resetDelay: 1000 })
28
+ return (
29
+ <div>
30
+ <button onClick={() => copy('test')}>Copy</button>
31
+ <span data-testid="copied">{String(copied)}</span>
32
+ </div>
33
+ )
34
+ }
35
+
36
+ const { getByText, getByTestId } = render(<TestComponent />)
37
+ expect(getByTestId('copied').textContent).toBe('false')
38
+
39
+ fireEvent.click(getByText('Copy'))
40
+ await waitFor(() => {
41
+ expect(writeText).toHaveBeenCalledWith('test')
42
+ })
43
+ await waitFor(() => {
44
+ expect(getByTestId('copied').textContent).toBe('true')
45
+ })
46
+
47
+ vi.advanceTimersByTime(1000)
48
+ await waitFor(() => {
49
+ expect(getByTestId('copied').textContent).toBe('false')
50
+ })
51
+ })
52
+
53
+ it('copy returns false and sets error when clipboard API fails', async () => {
54
+ const writeText = vi.fn().mockRejectedValue(new Error('Permission denied'))
55
+ Object.defineProperty(global, 'navigator', {
56
+ value: { ...originalNavigator, clipboard: { writeText } },
57
+ writable: true,
58
+ })
59
+
60
+ function TestComponent() {
61
+ const { copy, error } = useClipboard()
62
+ return (
63
+ <div>
64
+ <button onClick={() => copy('test')}>Copy</button>
65
+ <span data-testid="error">{error?.message ?? 'none'}</span>
66
+ </div>
67
+ )
68
+ }
69
+
70
+ const { getByText, getByTestId } = render(<TestComponent />)
71
+ fireEvent.click(getByText('Copy'))
72
+
73
+ await waitFor(() => {
74
+ expect(getByTestId('error').textContent).toBe('Permission denied')
75
+ })
76
+ })
77
+
78
+ it('paste returns text when clipboard API succeeds', async () => {
79
+ const readText = vi.fn().mockResolvedValue('pasted content')
80
+ Object.defineProperty(global, 'navigator', {
81
+ value: { ...originalNavigator, clipboard: { writeText: vi.fn(), readText } },
82
+ writable: true,
83
+ })
84
+
85
+ function TestComponent() {
86
+ const [text, setText] = useState('')
87
+ const { paste } = useClipboard()
88
+ const handlePaste = async () => {
89
+ const result = await paste()
90
+ setText(result)
91
+ }
92
+ return (
93
+ <div>
94
+ <button onClick={handlePaste}>Paste</button>
95
+ <span data-testid="text">{text}</span>
96
+ </div>
97
+ )
98
+ }
99
+
100
+ const { getByText, getByTestId } = render(<TestComponent />)
101
+
102
+ fireEvent.click(getByText('Paste'))
103
+ await waitFor(() => {
104
+ expect(readText).toHaveBeenCalled()
105
+ })
106
+ await waitFor(() => {
107
+ expect(getByTestId('text').textContent).toBe('pasted content')
108
+ })
109
+ })
110
+
111
+ it('reset clears copied and error state', async () => {
112
+ vi.useFakeTimers()
113
+ const writeText = vi.fn().mockResolvedValue(undefined)
114
+ Object.defineProperty(global, 'navigator', {
115
+ value: { ...originalNavigator, clipboard: { writeText } },
116
+ writable: true,
117
+ })
118
+
119
+ function TestComponent() {
120
+ const { copy, copied, reset } = useClipboard({ resetDelay: 5000 })
121
+ return (
122
+ <div>
123
+ <button onClick={() => copy('test')}>Copy</button>
124
+ <button onClick={reset}>Reset</button>
125
+ <span data-testid="copied">{String(copied)}</span>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ const { getByText, getByTestId } = render(<TestComponent />)
131
+ fireEvent.click(getByText('Copy'))
132
+ await waitFor(() => expect(getByTestId('copied').textContent).toBe('true'))
133
+ fireEvent.click(getByText('Reset'))
134
+ expect(getByTestId('copied').textContent).toBe('false')
135
+ })
136
+
137
+ it('handles missing clipboard API', async () => {
138
+ Object.defineProperty(global, 'navigator', {
139
+ value: { ...originalNavigator, clipboard: undefined },
140
+ writable: true,
141
+ })
142
+
143
+ function TestComponent() {
144
+ const { copy, error } = useClipboard()
145
+ return (
146
+ <div>
147
+ <button onClick={() => copy('test')}>Copy</button>
148
+ <span data-testid="error">{error?.message ?? 'none'}</span>
149
+ </div>
150
+ )
151
+ }
152
+
153
+ const { getByText, getByTestId } = render(<TestComponent />)
154
+ fireEvent.click(getByText('Copy'))
155
+ await waitFor(() => {
156
+ expect(getByTestId('error').textContent).toBe('Clipboard API is not available')
157
+ })
158
+ })
159
+ })
@@ -0,0 +1,140 @@
1
+ /** @jsx h */
2
+ import { h } from 'preact'
3
+ import { render, waitFor } from '@testing-library/preact'
4
+ import { useNetworkState } from '../src/useNetworkState'
5
+
6
+ describe('useNetworkState', () => {
7
+ const originalNavigator = global.navigator
8
+ const originalAddEventListener = window.addEventListener
9
+ const originalRemoveEventListener = window.removeEventListener
10
+
11
+ afterEach(() => {
12
+ Object.defineProperty(global, 'navigator', {
13
+ value: originalNavigator,
14
+ writable: true,
15
+ })
16
+ window.addEventListener = originalAddEventListener
17
+ window.removeEventListener = originalRemoveEventListener
18
+ })
19
+
20
+ it('returns online: true when navigator.onLine is true', () => {
21
+ Object.defineProperty(global, 'navigator', {
22
+ value: { ...originalNavigator, onLine: true },
23
+ writable: true,
24
+ })
25
+
26
+ function TestComponent() {
27
+ const state = useNetworkState()
28
+ return (
29
+ <div>
30
+ <span data-testid="online">{String(state.online)}</span>
31
+ </div>
32
+ )
33
+ }
34
+
35
+ const { getByTestId } = render(<TestComponent />)
36
+ expect(getByTestId('online').textContent).toBe('true')
37
+ })
38
+
39
+ it('returns online: false when navigator.onLine is false', () => {
40
+ Object.defineProperty(global, 'navigator', {
41
+ value: { ...originalNavigator, onLine: false },
42
+ writable: true,
43
+ })
44
+
45
+ function TestComponent() {
46
+ const state = useNetworkState()
47
+ return (
48
+ <div>
49
+ <span data-testid="online">{String(state.online)}</span>
50
+ </div>
51
+ )
52
+ }
53
+
54
+ const { getByTestId } = render(<TestComponent />)
55
+ expect(getByTestId('online').textContent).toBe('false')
56
+ })
57
+
58
+ it('includes connection info when navigator.connection is available', () => {
59
+ const mockConnection = {
60
+ effectiveType: '4g',
61
+ downlink: 10,
62
+ rtt: 50,
63
+ saveData: false,
64
+ type: 'wifi',
65
+ addEventListener: vi.fn(),
66
+ removeEventListener: vi.fn(),
67
+ }
68
+
69
+ Object.defineProperty(global, 'navigator', {
70
+ value: {
71
+ ...originalNavigator,
72
+ onLine: true,
73
+ connection: mockConnection,
74
+ },
75
+ writable: true,
76
+ })
77
+
78
+ function TestComponent() {
79
+ const state = useNetworkState()
80
+ return (
81
+ <div>
82
+ <span data-testid="effectiveType">{state.effectiveType ?? 'none'}</span>
83
+ <span data-testid="downlink">{state.downlink ?? 'none'}</span>
84
+ <span data-testid="rtt">{state.rtt ?? 'none'}</span>
85
+ <span data-testid="saveData">{String(state.saveData ?? 'none')}</span>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ const { getByTestId } = render(<TestComponent />)
91
+ expect(getByTestId('effectiveType').textContent).toBe('4g')
92
+ expect(getByTestId('downlink').textContent).toBe('10')
93
+ expect(getByTestId('rtt').textContent).toBe('50')
94
+ expect(getByTestId('saveData').textContent).toBe('false')
95
+ })
96
+
97
+ it('updates when online/offline events fire', async () => {
98
+ let onlineHandler: () => void = () => { }
99
+ let offlineHandler: () => void = () => { }
100
+
101
+ window.addEventListener = vi.fn((event: string, handler: () => void) => {
102
+ if (event === 'online') onlineHandler = handler
103
+ if (event === 'offline') offlineHandler = handler
104
+ }) as any
105
+ window.removeEventListener = vi.fn()
106
+
107
+ Object.defineProperty(global, 'navigator', {
108
+ value: { ...originalNavigator, onLine: true },
109
+ writable: true,
110
+ })
111
+
112
+ function TestComponent() {
113
+ const state = useNetworkState()
114
+ return <span data-testid="online">{String(state.online)}</span>
115
+ }
116
+
117
+ const { getByTestId } = render(<TestComponent />)
118
+ expect(getByTestId('online').textContent).toBe('true')
119
+
120
+ Object.defineProperty(global, 'navigator', {
121
+ value: { ...originalNavigator, onLine: false },
122
+ writable: true,
123
+ })
124
+ offlineHandler()
125
+
126
+ await waitFor(() => {
127
+ expect(getByTestId('online').textContent).toBe('false')
128
+ })
129
+
130
+ Object.defineProperty(global, 'navigator', {
131
+ value: { ...originalNavigator, onLine: true },
132
+ writable: true,
133
+ })
134
+ onlineHandler()
135
+
136
+ await waitFor(() => {
137
+ expect(getByTestId('online').textContent).toBe('true')
138
+ })
139
+ })
140
+ })
@@ -0,0 +1,119 @@
1
+ /** @jsx h */
2
+ import { h } from 'preact'
3
+ import { render, waitFor } from '@testing-library/preact'
4
+ import { usePreferredTheme } from '../src/usePreferredTheme'
5
+
6
+ describe('usePreferredTheme', () => {
7
+ const originalMatchMedia = window.matchMedia
8
+
9
+ afterEach(() => {
10
+ window.matchMedia = originalMatchMedia
11
+ })
12
+
13
+ function createMockMediaQuery(matches: boolean) {
14
+ return {
15
+ matches,
16
+ media: '(prefers-color-scheme: dark)',
17
+ onchange: null as ((e: MediaQueryListEvent) => void) | null,
18
+ addEventListener: vi.fn((_event: string, handler: (e: MediaQueryListEvent) => void) => {
19
+ ; (createMockMediaQuery as any)._handler = handler
20
+ }),
21
+ removeEventListener: vi.fn(),
22
+ addListener: vi.fn(),
23
+ removeListener: vi.fn(),
24
+ dispatchEvent: vi.fn(),
25
+ }
26
+ }
27
+
28
+ it('returns "dark" when prefers-color-scheme: dark matches', () => {
29
+ const darkQuery = createMockMediaQuery(true)
30
+ const lightQuery = createMockMediaQuery(false)
31
+
32
+ window.matchMedia = vi.fn((query: string) => {
33
+ if (query.includes('dark')) return darkQuery
34
+ return lightQuery
35
+ }) as any
36
+
37
+ function TestComponent() {
38
+ const theme = usePreferredTheme()
39
+ return <div data-testid="theme">{theme}</div>
40
+ }
41
+
42
+ const { getByTestId } = render(<TestComponent />)
43
+ expect(getByTestId('theme').textContent).toBe('dark')
44
+ })
45
+
46
+ it('returns "light" when prefers-color-scheme: light matches', () => {
47
+ const darkQuery = createMockMediaQuery(false)
48
+ const lightQuery = createMockMediaQuery(true)
49
+
50
+ window.matchMedia = vi.fn((query: string) => {
51
+ if (query.includes('dark')) return darkQuery
52
+ return lightQuery
53
+ }) as any
54
+
55
+ function TestComponent() {
56
+ const theme = usePreferredTheme()
57
+ return <div data-testid="theme">{theme}</div>
58
+ }
59
+
60
+ const { getByTestId } = render(<TestComponent />)
61
+ expect(getByTestId('theme').textContent).toBe('light')
62
+ })
63
+
64
+ it('returns "no-preference" when neither dark nor light matches', () => {
65
+ const darkQuery = createMockMediaQuery(false)
66
+ const lightQuery = createMockMediaQuery(false)
67
+
68
+ window.matchMedia = vi.fn((query: string) => {
69
+ if (query.includes('dark')) return darkQuery
70
+ return lightQuery
71
+ }) as any
72
+
73
+ function TestComponent() {
74
+ const theme = usePreferredTheme()
75
+ return <div data-testid="theme">{theme}</div>
76
+ }
77
+
78
+ const { getByTestId } = render(<TestComponent />)
79
+ expect(getByTestId('theme').textContent).toBe('no-preference')
80
+ })
81
+
82
+ it('updates when media query change event fires', async () => {
83
+ let changeHandler: ((e: MediaQueryListEvent) => void) | null = null
84
+ const darkQuery = {
85
+ matches: true,
86
+ media: '(prefers-color-scheme: dark)',
87
+ onchange: null,
88
+ addEventListener: vi.fn((_event: string, handler: (e: MediaQueryListEvent) => void) => {
89
+ changeHandler = handler
90
+ }),
91
+ removeEventListener: vi.fn(),
92
+ addListener: vi.fn(),
93
+ removeListener: vi.fn(),
94
+ dispatchEvent: vi.fn(),
95
+ }
96
+ const lightQuery = createMockMediaQuery(false)
97
+
98
+ window.matchMedia = vi.fn((query: string) => {
99
+ if (query.includes('dark')) return darkQuery
100
+ return lightQuery
101
+ }) as any
102
+
103
+ function TestComponent() {
104
+ const theme = usePreferredTheme()
105
+ return <div data-testid="theme">{theme}</div>
106
+ }
107
+
108
+ const { getByTestId } = render(<TestComponent />)
109
+ expect(getByTestId('theme').textContent).toBe('dark')
110
+
111
+ // Simulate user switching to light mode
112
+ ; (darkQuery as any).matches = false
113
+ changeHandler?.({ matches: false, media: '(prefers-color-scheme: dark)' } as MediaQueryListEvent)
114
+
115
+ await waitFor(() => {
116
+ expect(getByTestId('theme').textContent).toBe('light')
117
+ })
118
+ })
119
+ })
@@ -0,0 +1,156 @@
1
+ /** @jsx h */
2
+ import { h, ComponentChildren } from 'preact'
3
+ import { render, screen } from '@testing-library/preact'
4
+ import '@testing-library/jest-dom'
5
+ import { useWrappedChildren } from '../src/useWrappedChildren'
6
+
7
+ interface TestChildProps {
8
+ className?: string
9
+ 'data-testid'?: string
10
+ style?: Record<string, string>
11
+ children?: ComponentChildren
12
+ }
13
+
14
+ function TestChild({ className, 'data-testid': testId, style, children }: TestChildProps) {
15
+ return (
16
+ <div className={className} data-testid={testId} style={style}>
17
+ {children}
18
+ </div>
19
+ )
20
+ }
21
+
22
+ function ParentWithWrappedChildren({
23
+ children,
24
+ injectProps,
25
+ mergeStrategy
26
+ }: {
27
+ children: ComponentChildren
28
+ injectProps: Record<string, any>
29
+ mergeStrategy?: 'override' | 'preserve'
30
+ }) {
31
+ const wrappedChildren = useWrappedChildren(children, injectProps, mergeStrategy)
32
+ return <div data-testid="parent">{wrappedChildren}</div>
33
+ }
34
+
35
+ describe('useWrappedChildren', () => {
36
+ it('injects props into single child component', () => {
37
+ const injectProps = { className: 'injected', 'data-testid': 'child' }
38
+
39
+ render(
40
+ <ParentWithWrappedChildren injectProps={injectProps}>
41
+ <TestChild>Original content</TestChild>
42
+ </ParentWithWrappedChildren>
43
+ )
44
+
45
+ const child = screen.getByTestId('child')
46
+ expect(child).toHaveClass('injected')
47
+ expect(child).toHaveTextContent('Original content')
48
+ })
49
+
50
+ it('injects props into multiple child components', () => {
51
+ const injectProps = { className: 'injected' }
52
+
53
+ render(
54
+ <ParentWithWrappedChildren injectProps={injectProps}>
55
+ <TestChild data-testid="child1">First child</TestChild>
56
+ <TestChild data-testid="child2">Second child</TestChild>
57
+ </ParentWithWrappedChildren>
58
+ )
59
+
60
+ const child1 = screen.getByTestId('child1')
61
+ const child2 = screen.getByTestId('child2')
62
+
63
+ expect(child1).toHaveClass('injected')
64
+ expect(child2).toHaveClass('injected')
65
+ expect(child1).toHaveTextContent('First child')
66
+ expect(child2).toHaveTextContent('Second child')
67
+ })
68
+
69
+ it('preserves existing props by default', () => {
70
+ const injectProps = { className: 'injected', style: { color: 'blue' } }
71
+
72
+ render(
73
+ <ParentWithWrappedChildren injectProps={injectProps}>
74
+ <TestChild
75
+ className="existing"
76
+ data-testid="child"
77
+ style={{ backgroundColor: 'red' }}
78
+ >
79
+ Content
80
+ </TestChild>
81
+ </ParentWithWrappedChildren>
82
+ )
83
+
84
+ const child = screen.getByTestId('child')
85
+ expect(child).toHaveClass('existing') // existing prop preserved
86
+ expect(child).not.toHaveClass('injected') // injected prop not applied due to conflict
87
+
88
+ // Check computed styles
89
+ const computedStyle = getComputedStyle(child)
90
+ expect(computedStyle.backgroundColor).toBe('rgb(255, 0, 0)') // red
91
+ expect(computedStyle.color).toBe('rgb(0, 0, 255)') // blue
92
+ })
93
+
94
+ it('overrides existing props when mergeStrategy is override', () => {
95
+ const injectProps = { className: 'injected', 'data-testid': 'child' }
96
+
97
+ render(
98
+ <ParentWithWrappedChildren
99
+ injectProps={injectProps}
100
+ mergeStrategy="override"
101
+ >
102
+ <TestChild className="existing">Content</TestChild>
103
+ </ParentWithWrappedChildren>
104
+ )
105
+
106
+ const child = screen.getByTestId('child')
107
+ expect(child).toHaveClass('injected') // injected prop applied
108
+ expect(child).not.toHaveClass('existing') // existing prop overridden
109
+ })
110
+
111
+ it('handles non-element children gracefully', () => {
112
+ const injectProps = { className: 'injected' }
113
+
114
+ render(
115
+ <ParentWithWrappedChildren injectProps={injectProps}>
116
+ Plain text content
117
+ <TestChild data-testid="child">Element content</TestChild>
118
+ {42}
119
+ {null}
120
+ </ParentWithWrappedChildren>
121
+ )
122
+
123
+ const parent = screen.getByTestId('parent')
124
+ const child = screen.getByTestId('child')
125
+
126
+ expect(parent).toHaveTextContent('Plain text content')
127
+ expect(parent).toHaveTextContent('42')
128
+ expect(child).toHaveClass('injected')
129
+ })
130
+
131
+ it('returns undefined children unchanged', () => {
132
+ const injectProps = { className: 'injected' }
133
+
134
+ render(
135
+ <ParentWithWrappedChildren injectProps={injectProps}>
136
+ {undefined}
137
+ </ParentWithWrappedChildren>
138
+ )
139
+
140
+ const parent = screen.getByTestId('parent')
141
+ expect(parent).toBeEmptyDOMElement()
142
+ })
143
+
144
+ it('returns null children unchanged', () => {
145
+ const injectProps = { className: 'injected' }
146
+
147
+ render(
148
+ <ParentWithWrappedChildren injectProps={injectProps}>
149
+ {null}
150
+ </ParentWithWrappedChildren>
151
+ )
152
+
153
+ const parent = screen.getByTestId('parent')
154
+ expect(parent).toBeEmptyDOMElement()
155
+ })
156
+ })