@linktr.ee/messaging-react 1.0.0 → 1.0.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.
Files changed (40) hide show
  1. package/package.json +3 -2
  2. package/src/components/ActionButton/ActionButton.stories.tsx +46 -0
  3. package/src/components/ActionButton/ActionButton.test.tsx +112 -0
  4. package/src/components/ActionButton/index.tsx +33 -0
  5. package/src/components/Avatar/Avatar.stories.tsx +144 -0
  6. package/src/components/Avatar/avatarColors.ts +36 -0
  7. package/src/components/Avatar/index.tsx +64 -0
  8. package/src/components/ChannelList/ChannelList.stories.tsx +48 -0
  9. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +303 -0
  10. package/src/components/ChannelList/CustomChannelPreview.tsx +114 -0
  11. package/src/components/ChannelList/index.tsx +129 -0
  12. package/src/components/ChannelView.tsx +422 -0
  13. package/src/components/CloseButton/index.tsx +16 -0
  14. package/src/components/IconButton/IconButton.stories.tsx +40 -0
  15. package/src/components/IconButton/index.tsx +32 -0
  16. package/src/components/Loading/Loading.stories.tsx +24 -0
  17. package/src/components/Loading/index.tsx +50 -0
  18. package/src/components/MessagingShell/EmptyState.stories.tsx +38 -0
  19. package/src/components/MessagingShell/EmptyState.tsx +55 -0
  20. package/src/components/MessagingShell/ErrorState.stories.tsx +42 -0
  21. package/src/components/MessagingShell/ErrorState.tsx +33 -0
  22. package/src/components/MessagingShell/LoadingState.stories.tsx +26 -0
  23. package/src/components/MessagingShell/LoadingState.tsx +15 -0
  24. package/src/components/MessagingShell/index.tsx +298 -0
  25. package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +188 -0
  26. package/src/components/ParticipantPicker/ParticipantItem.tsx +59 -0
  27. package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +54 -0
  28. package/src/components/ParticipantPicker/ParticipantPicker.tsx +196 -0
  29. package/src/components/ParticipantPicker/index.tsx +234 -0
  30. package/src/components/SearchInput/SearchInput.stories.tsx +33 -0
  31. package/src/components/SearchInput/SearchInput.test.tsx +108 -0
  32. package/src/components/SearchInput/index.tsx +50 -0
  33. package/src/hooks/useMessaging.ts +9 -0
  34. package/src/hooks/useParticipants.ts +92 -0
  35. package/src/index.ts +26 -0
  36. package/src/providers/MessagingProvider.tsx +282 -0
  37. package/src/stories/mocks.tsx +157 -0
  38. package/src/test/setup.ts +30 -0
  39. package/src/test/utils.tsx +23 -0
  40. package/src/types.ts +113 -0
@@ -0,0 +1,54 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import { ParticipantPicker } from './index'
3
+ import { mockParticipantSource } from '../../stories/mocks'
4
+ import React from 'react'
5
+
6
+ type ComponentProps = React.ComponentProps<typeof ParticipantPicker>
7
+
8
+ const meta: Meta<ComponentProps> = {
9
+ title: 'ParticipantPicker',
10
+ component: ParticipantPicker,
11
+ parameters: {
12
+ layout: 'fullscreen',
13
+ },
14
+ }
15
+ export default meta
16
+
17
+ const Template: StoryFn<ComponentProps> = (args) => {
18
+ return (
19
+ <div className="h-screen w-full">
20
+ <ParticipantPicker {...args} />
21
+ </div>
22
+ )
23
+ }
24
+
25
+ export const Default: StoryFn<ComponentProps> = Template.bind({})
26
+ Default.args = {
27
+ participantSource: mockParticipantSource,
28
+ participantLabel: 'participants',
29
+ searchPlaceholder: 'Search participants...',
30
+ onClose: () => console.log('Close clicked'),
31
+ onSelectParticipant: (participant) => console.log('Selected:', participant),
32
+ existingParticipantIds: new Set(),
33
+ }
34
+
35
+ export const WithExistingParticipants: StoryFn<ComponentProps> = Template.bind({})
36
+ WithExistingParticipants.args = {
37
+ participantSource: mockParticipantSource,
38
+ participantLabel: 'participants',
39
+ searchPlaceholder: 'Search participants...',
40
+ onClose: () => console.log('Close clicked'),
41
+ onSelectParticipant: (participant) => console.log('Selected:', participant),
42
+ existingParticipantIds: new Set(['participant-1', 'participant-3']),
43
+ }
44
+
45
+ export const CustomLabels: StoryFn<ComponentProps> = Template.bind({})
46
+ CustomLabels.args = {
47
+ participantSource: mockParticipantSource,
48
+ participantLabel: 'followers',
49
+ searchPlaceholder: 'Search followers...',
50
+ onClose: () => console.log('Close clicked'),
51
+ onSelectParticipant: (participant) => console.log('Selected:', participant),
52
+ existingParticipantIds: new Set(),
53
+ }
54
+
@@ -0,0 +1,196 @@
1
+ import React, { useCallback, useEffect, useState, useRef } from 'react';
2
+ import { ChatCircleDotsIcon } from "@phosphor-icons/react/dist/csr/ChatCircleDots";
3
+ import classNames from 'classnames';
4
+
5
+ import type { ParticipantPickerProps, Participant } from '../../types';
6
+ import { CloseButton } from '../CloseButton';
7
+ import { SearchInput } from '../SearchInput';
8
+ import Loading from '../Loading';
9
+ import { ParticipantItem } from './ParticipantItem';
10
+ import { useMessagingContext } from '../../providers/MessagingProvider';
11
+
12
+ /**
13
+ * Generic participant picker component for starting conversations
14
+ */
15
+ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
16
+ participantSource,
17
+ onSelectParticipant,
18
+ onClose,
19
+ existingParticipantIds = new Set(),
20
+ participantLabel = 'participants',
21
+ searchPlaceholder = 'Search participants...',
22
+ className,
23
+ }) => {
24
+ const { debug } = useMessagingContext();
25
+ const [searchQuery, setSearchQuery] = useState('');
26
+ const [participants, setParticipants] = useState<Participant[]>([]);
27
+ const [loading, setLoading] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [startingChatWithId, setStartingChatWithId] = useState<string | null>(null);
30
+
31
+ // Track if we've already loaded participants to prevent repeated loading
32
+ const loadedRef = useRef(false);
33
+
34
+ // Load participants initially - wait for participantSource to finish loading first
35
+ useEffect(() => {
36
+ // Wait for the participantSource to finish loading before we try to load participants
37
+ if (participantSource.loading) {
38
+ if (debug) {
39
+ console.log('[ParticipantPicker] Waiting for participant source to finish loading...');
40
+ }
41
+ return;
42
+ }
43
+
44
+ if (loadedRef.current) return; // Prevent multiple loads
45
+
46
+ const loadInitialParticipants = async () => {
47
+ if (debug) {
48
+ console.log('[ParticipantPicker] Loading initial participants...');
49
+ }
50
+ setLoading(true);
51
+ setError(null);
52
+
53
+ try {
54
+ const result = await participantSource.loadParticipants({
55
+ search: '', // Load all participants initially
56
+ limit: 100
57
+ });
58
+ setParticipants(result.participants);
59
+ loadedRef.current = true; // Mark as loaded
60
+ if (debug) {
61
+ console.log('[ParticipantPicker] Participants loaded successfully:', result.participants.length);
62
+ }
63
+ } catch (err) {
64
+ const errorMessage = err instanceof Error ? err.message : 'Failed to load participants';
65
+ setError(errorMessage);
66
+ console.error('[ParticipantPicker] Failed to load participants:', err);
67
+ // Don't mark as loaded on error, allow retry
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ };
72
+
73
+ loadInitialParticipants();
74
+ }, [participantSource.loading, debug]); // Re-run when loading state changes
75
+
76
+ // Filter participants by search query and existing participants
77
+ const availableParticipants = participants
78
+ .filter(participant => !existingParticipantIds.has(participant.id))
79
+ .filter(participant => {
80
+ if (!searchQuery) return true;
81
+ const searchLower = searchQuery.toLowerCase();
82
+ return (
83
+ participant.name.toLowerCase().includes(searchLower) ||
84
+ participant.email?.toLowerCase().includes(searchLower) ||
85
+ false
86
+ );
87
+ });
88
+
89
+ const handleSelectParticipant = useCallback(async (participant: Participant) => {
90
+ if (startingChatWithId) return; // Prevent multiple clicks
91
+
92
+ setStartingChatWithId(participant.id);
93
+ try {
94
+ await onSelectParticipant(participant);
95
+ } catch (error) {
96
+ console.error('[ParticipantPicker] Failed to start chat:', error);
97
+ // Reset the loading state on error
98
+ setStartingChatWithId(null);
99
+ }
100
+ // Note: Don't reset startingChatWithId on success because the dialog will close
101
+ }, [onSelectParticipant, startingChatWithId]);
102
+
103
+ const handleKeyDown = (event: React.KeyboardEvent, participant: Participant) => {
104
+ if (event.key === 'Enter' || event.key === ' ') {
105
+ event.preventDefault();
106
+ handleSelectParticipant(participant);
107
+ }
108
+ };
109
+
110
+ return (
111
+ <div className={classNames('flex flex-col h-full', className)}>
112
+ {/* Header */}
113
+ <div className="px-4 py-4 border-b border-sand bg-chalk">
114
+ <div className="flex items-center justify-between mb-3">
115
+ <h2 className="text-lg font-semibold text-charcoal">
116
+ Start a new Conversation
117
+ </h2>
118
+ <CloseButton onClick={onClose} />
119
+ </div>
120
+
121
+ <p className="text-xs text-stone mb-3">
122
+ Select a {participantLabel.slice(0, -1)} to start messaging ({availableParticipants.length} available)
123
+ {participantSource.totalCount !== undefined && ` • ${participantSource.totalCount} ${participantLabel} total`}
124
+ </p>
125
+
126
+ <SearchInput
127
+ searchQuery={searchQuery}
128
+ setSearchQuery={setSearchQuery}
129
+ placeholder={searchPlaceholder}
130
+ />
131
+ </div>
132
+
133
+ {/* Error State */}
134
+ {error && (
135
+ <div className="p-4 text-sm text-danger bg-danger-alt">
136
+ Error loading {participantLabel}: {error}
137
+ </div>
138
+ )}
139
+
140
+ {/* Participants List */}
141
+ <div className="flex-1 overflow-auto">
142
+ {loading && availableParticipants.length === 0 ? (
143
+ <div className="h-32 flex items-center justify-center">
144
+ <div className="flex items-center space-x-2">
145
+ <div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
146
+ <span className="text-sm text-stone">Loading {participantLabel}...</span>
147
+ </div>
148
+ </div>
149
+ ) : availableParticipants.length === 0 ? (
150
+ <div className="p-6 text-center">
151
+ <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-sand">
152
+ <ChatCircleDotsIcon className="h-8 w-8 text-charcoal" />
153
+ </div>
154
+ <h3 className="text-sm font-semibold text-charcoal mb-2">
155
+ {searchQuery
156
+ ? `No ${participantLabel} found`
157
+ : participants.length > 0
158
+ ? `Already chatting with all ${participantLabel}`
159
+ : `No ${participantLabel} yet`}
160
+ </h3>
161
+ <p className="text-xs text-stone">
162
+ {searchQuery
163
+ ? 'Try a different search term'
164
+ : participants.length > 0
165
+ ? `You have existing conversations with all your ${participantLabel}`
166
+ : `${participantLabel.charAt(0).toUpperCase() + participantLabel.slice(1)} will appear here`}
167
+ </p>
168
+ </div>
169
+ ) : (
170
+ <ul className="space-y-0">
171
+ {availableParticipants.map((participant) => {
172
+ const displayName = participant.name || participant.email || participant.id;
173
+ const displaySecondary = participant.email && participant.name ? participant.email : participant.phone;
174
+
175
+ return (
176
+ <ParticipantItem key={participant.id} participant={participant} handleSelectParticipant={handleSelectParticipant} handleKeyDown={handleKeyDown} displayName={displayName} displaySecondary={displaySecondary} />
177
+ );
178
+ })}
179
+
180
+ {/* Loading indicator */}
181
+ {loading && (
182
+ <li className="p-4 flex justify-center">
183
+ <div className="flex items-center space-x-2">
184
+ <Loading className='w-6 h-6' />
185
+ <span className="text-sm text-stone">Loading more...</span>
186
+ </div>
187
+ </li>
188
+ )}
189
+ </ul>
190
+ )}
191
+ </div>
192
+ </div>
193
+ );
194
+ };
195
+
196
+
@@ -0,0 +1,234 @@
1
+ import React, { useCallback, useEffect, useState, useRef } from 'react';
2
+ import { ChatCircleDotsIcon } from "@phosphor-icons/react/dist/csr/ChatCircleDots";
3
+ import { SpinnerGapIcon } from "@phosphor-icons/react/dist/csr/SpinnerGap";
4
+ import classNames from 'classnames';
5
+
6
+ import type { ParticipantPickerProps, Participant } from '../../types';
7
+ import { CloseButton } from '../CloseButton';
8
+ import { SearchInput } from '../SearchInput';
9
+ import { useMessagingContext } from '../../providers/MessagingProvider';
10
+ import { Avatar } from '../Avatar';
11
+
12
+ /**
13
+ * Generic participant picker component for starting conversations
14
+ */
15
+ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
16
+ participantSource,
17
+ onSelectParticipant,
18
+ onClose,
19
+ existingParticipantIds = new Set(),
20
+ participantLabel = 'participants',
21
+ searchPlaceholder = 'Search participants...',
22
+ className,
23
+ }) => {
24
+ const { debug } = useMessagingContext();
25
+ const [searchQuery, setSearchQuery] = useState('');
26
+ const [participants, setParticipants] = useState<Participant[]>([]);
27
+ const [loading, setLoading] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [startingChatWithId, setStartingChatWithId] = useState<string | null>(null);
30
+
31
+ // Track if we've already loaded participants to prevent repeated loading
32
+ const loadedRef = useRef(false);
33
+
34
+ // Load participants initially - wait for participantSource to finish loading first
35
+ useEffect(() => {
36
+ // Wait for the participantSource to finish loading before we try to load participants
37
+ if (participantSource.loading) {
38
+ if (debug) {
39
+ console.log('[ParticipantPicker] Waiting for participant source to finish loading...');
40
+ }
41
+ return;
42
+ }
43
+
44
+ if (loadedRef.current) return; // Prevent multiple loads
45
+
46
+ const loadInitialParticipants = async () => {
47
+ if (debug) {
48
+ console.log('[ParticipantPicker] Loading initial participants...');
49
+ }
50
+ setLoading(true);
51
+ setError(null);
52
+
53
+ try {
54
+ const result = await participantSource.loadParticipants({
55
+ search: '', // Load all participants initially
56
+ limit: 100
57
+ });
58
+ setParticipants(result.participants);
59
+ loadedRef.current = true; // Mark as loaded
60
+ if (debug) {
61
+ console.log('[ParticipantPicker] Participants loaded successfully:', result.participants.length);
62
+ }
63
+ } catch (err) {
64
+ const errorMessage = err instanceof Error ? err.message : 'Failed to load participants';
65
+ setError(errorMessage);
66
+ console.error('[ParticipantPicker] Failed to load participants:', err);
67
+ // Don't mark as loaded on error, allow retry
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ };
72
+
73
+ loadInitialParticipants();
74
+ }, [participantSource.loading, debug]); // Re-run when loading state changes
75
+
76
+ // Filter participants by search query and existing participants
77
+ const availableParticipants = participants
78
+ .filter(participant => !existingParticipantIds.has(participant.id))
79
+ .filter(participant => {
80
+ if (!searchQuery) return true;
81
+ const searchLower = searchQuery.toLowerCase();
82
+ return (
83
+ participant.name.toLowerCase().includes(searchLower) ||
84
+ participant.email?.toLowerCase().includes(searchLower) ||
85
+ false
86
+ );
87
+ });
88
+
89
+ const handleSelectParticipant = useCallback(async (participant: Participant) => {
90
+ if (startingChatWithId) return; // Prevent multiple clicks
91
+
92
+ setStartingChatWithId(participant.id);
93
+ try {
94
+ await onSelectParticipant(participant);
95
+ } catch (error) {
96
+ console.error('[ParticipantPicker] Failed to start chat:', error);
97
+ // Reset the loading state on error
98
+ setStartingChatWithId(null);
99
+ }
100
+ // Note: Don't reset startingChatWithId on success because the dialog will close
101
+ }, [onSelectParticipant, startingChatWithId]);
102
+
103
+ const handleKeyDown = (event: React.KeyboardEvent, participant: Participant) => {
104
+ if (event.key === 'Enter' || event.key === ' ') {
105
+ event.preventDefault();
106
+ handleSelectParticipant(participant);
107
+ }
108
+ };
109
+
110
+ return (
111
+ <div className={classNames('flex flex-col h-full', className)}>
112
+ {/* Header */}
113
+ <div className="px-4 py-4 border-b border-sand bg-chalk">
114
+ <div className="flex items-center justify-between mb-3">
115
+ <h2 className="text-lg font-semibold text-charcoal">
116
+ Start a new Conversation
117
+ </h2>
118
+ <CloseButton onClick={onClose} />
119
+ </div>
120
+
121
+ <p className="text-xs text-stone mb-3">
122
+ Select a {participantLabel.slice(0, -1)} to start messaging ({availableParticipants.length} available)
123
+ {participantSource.totalCount !== undefined && ` • ${participantSource.totalCount} ${participantLabel} total`}
124
+ </p>
125
+
126
+ <SearchInput
127
+ searchQuery={searchQuery}
128
+ setSearchQuery={setSearchQuery}
129
+ placeholder={searchPlaceholder}
130
+ />
131
+ </div>
132
+
133
+ {/* Error State */}
134
+ {error && (
135
+ <div className="p-4 text-sm text-danger bg-danger-alt">
136
+ Error loading {participantLabel}: {error}
137
+ </div>
138
+ )}
139
+
140
+ {/* Participants List */}
141
+ <div className="flex-1 overflow-auto">
142
+ {loading && availableParticipants.length === 0 ? (
143
+ <div className="h-32 flex items-center justify-center">
144
+ <div className="flex items-center space-x-2">
145
+ <div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
146
+ <span className="text-sm text-stone">Loading {participantLabel}...</span>
147
+ </div>
148
+ </div>
149
+ ) : availableParticipants.length === 0 ? (
150
+ <div className="p-6 text-center">
151
+ <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-sand">
152
+ <ChatCircleDotsIcon className="h-8 w-8 text-charcoal" />
153
+ </div>
154
+ <h3 className="text-sm font-semibold text-charcoal mb-2">
155
+ {searchQuery
156
+ ? `No ${participantLabel} found`
157
+ : participants.length > 0
158
+ ? `Already chatting with all ${participantLabel}`
159
+ : `No ${participantLabel} yet`}
160
+ </h3>
161
+ <p className="text-xs text-stone">
162
+ {searchQuery
163
+ ? 'Try a different search term'
164
+ : participants.length > 0
165
+ ? `You have existing conversations with all your ${participantLabel}`
166
+ : `${participantLabel.charAt(0).toUpperCase() + participantLabel.slice(1)} will appear here`}
167
+ </p>
168
+ </div>
169
+ ) : (
170
+ <ul className="space-y-0">
171
+ {availableParticipants.map((participant) => {
172
+ const displayName = participant.name || participant.email || participant.id;
173
+ const displaySecondary = participant.email && participant.name ? participant.email : participant.phone;
174
+
175
+ return (
176
+ <li key={participant.id}>
177
+ <button
178
+ type="button"
179
+ onClick={() => handleSelectParticipant(participant)}
180
+ onKeyDown={(e) => handleKeyDown(e, participant)}
181
+ className="w-full px-4 py-3 hover:bg-sand transition-colors border-b border-sand text-left focus:outline-none focus:ring-2 focus:ring-black"
182
+ >
183
+ <div className="flex items-center justify-between">
184
+ <div className="flex items-center space-x-3 flex-1 min-w-0">
185
+ {/* Avatar */}
186
+ <Avatar
187
+ id={participant.id}
188
+ name={displayName}
189
+ image={participant.image}
190
+ size={40}
191
+ />
192
+
193
+ {/* Info */}
194
+ <div className="flex-1 min-w-0">
195
+ <h4 className="text-sm font-medium text-charcoal truncate">
196
+ {displayName}
197
+ </h4>
198
+ {displaySecondary && (
199
+ <p className="text-xs text-stone truncate">
200
+ {displaySecondary}
201
+ </p>
202
+ )}
203
+ </div>
204
+ </div>
205
+
206
+ {/* Icon */}
207
+ <div className="flex-shrink-0">
208
+ {startingChatWithId === participant.id ? (
209
+ <SpinnerGapIcon className="h-5 w-5 text-primary animate-spin" />
210
+ ) : (
211
+ <ChatCircleDotsIcon className="h-5 w-5 text-stone" />
212
+ )}
213
+ </div>
214
+ </div>
215
+ </button>
216
+ </li>
217
+ );
218
+ })}
219
+
220
+ {/* Loading indicator */}
221
+ {loading && (
222
+ <li className="p-4 flex justify-center">
223
+ <div className="flex items-center space-x-2">
224
+ <div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
225
+ <span className="text-sm text-stone">Loading more...</span>
226
+ </div>
227
+ </li>
228
+ )}
229
+ </ul>
230
+ )}
231
+ </div>
232
+ </div>
233
+ );
234
+ };
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import { SearchInput } from '.'
3
+ import React from 'react'
4
+
5
+ type ComponentProps = React.ComponentProps<typeof SearchInput>
6
+
7
+ const meta: Meta<ComponentProps> = {
8
+ title: 'SearchInput',
9
+ component: SearchInput,
10
+ parameters: {
11
+ layout: 'centered',
12
+ },
13
+ }
14
+ export default meta
15
+
16
+ const Template: StoryFn<ComponentProps> = (args) => {
17
+ return (
18
+ <div className="p-12 w-[400px]">
19
+ <SearchInput {...args} />
20
+ </div>
21
+ )
22
+ }
23
+
24
+ export const Default: StoryFn<ComponentProps> = Template.bind({})
25
+ Default.args = {
26
+ placeholder: 'Search...',
27
+ }
28
+
29
+ export const CustomPlaceholder: StoryFn<ComponentProps> = Template.bind({})
30
+ CustomPlaceholder.args = {
31
+ placeholder: 'Find a conversation...',
32
+ }
33
+
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { renderWithProviders, screen, userEvent } from '../../test/utils';
3
+ import { SearchInput } from './index';
4
+
5
+ describe('SearchInput', () => {
6
+ describe('Rendering', () => {
7
+ it('renders with placeholder', () => {
8
+ renderWithProviders(
9
+ <SearchInput
10
+ searchQuery=""
11
+ setSearchQuery={vi.fn()}
12
+ placeholder="Search messages..."
13
+ />
14
+ );
15
+ expect(screen.getByPlaceholderText('Search messages...')).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders with search icon', () => {
19
+ renderWithProviders(
20
+ <SearchInput searchQuery="" setSearchQuery={vi.fn()} placeholder="Search" />
21
+ );
22
+ const searchIcon = document.querySelector('svg');
23
+ expect(searchIcon).toBeInTheDocument();
24
+ });
25
+
26
+ it('displays the current value', () => {
27
+ renderWithProviders(
28
+ <SearchInput searchQuery="test query" setSearchQuery={vi.fn()} placeholder="Search" />
29
+ );
30
+ const input = screen.getByRole('textbox');
31
+ expect(input).toHaveValue('test query');
32
+ });
33
+ });
34
+
35
+ describe('User Interaction', () => {
36
+ it('calls setSearchQuery when user types', async () => {
37
+ const handleChange = vi.fn();
38
+ const user = userEvent.setup();
39
+
40
+ renderWithProviders(
41
+ <SearchInput searchQuery="" setSearchQuery={handleChange} placeholder="Search" />
42
+ );
43
+
44
+ const input = screen.getByRole('textbox');
45
+ await user.type(input, 'hello');
46
+
47
+ expect(handleChange).toHaveBeenCalledTimes(5); // Once per character
48
+ });
49
+ });
50
+
51
+ describe('Clear Functionality', () => {
52
+ it('shows clear button when there is a value', () => {
53
+ renderWithProviders(
54
+ <SearchInput searchQuery="test" setSearchQuery={vi.fn()} placeholder="Search" />
55
+ );
56
+
57
+ const clearButton = screen.getByLabelText(/clear/i);
58
+ expect(clearButton).toBeInTheDocument();
59
+ });
60
+
61
+ it('does not show clear button when value is empty', () => {
62
+ renderWithProviders(
63
+ <SearchInput searchQuery="" setSearchQuery={vi.fn()} placeholder="Search" />
64
+ );
65
+
66
+ const clearButton = screen.queryByLabelText(/clear/i);
67
+ expect(clearButton).not.toBeInTheDocument();
68
+ });
69
+
70
+ it('calls setSearchQuery with empty string when clear is clicked', async () => {
71
+ const handleChange = vi.fn();
72
+ const user = userEvent.setup();
73
+
74
+ renderWithProviders(
75
+ <SearchInput searchQuery="test" setSearchQuery={handleChange} placeholder="Search" />
76
+ );
77
+
78
+ const clearButton = screen.getByLabelText(/clear/i);
79
+ await user.click(clearButton);
80
+
81
+ expect(handleChange).toHaveBeenCalledWith('');
82
+ });
83
+ });
84
+
85
+ describe('Accessibility', () => {
86
+ it('clear button has accessible label', () => {
87
+ renderWithProviders(
88
+ <SearchInput searchQuery="test" setSearchQuery={vi.fn()} placeholder="Search" />
89
+ );
90
+
91
+ const clearButton = screen.getByRole('button', { name: /clear search/i });
92
+ expect(clearButton).toBeInTheDocument();
93
+ });
94
+
95
+ it('is keyboard navigable', async () => {
96
+ const user = userEvent.setup();
97
+
98
+ renderWithProviders(
99
+ <SearchInput searchQuery="" setSearchQuery={vi.fn()} placeholder="Search" />
100
+ );
101
+
102
+ await user.tab();
103
+ const input = screen.getByRole('textbox');
104
+ expect(input).toHaveFocus();
105
+ });
106
+ });
107
+ });
108
+
@@ -0,0 +1,50 @@
1
+ import React, { useRef } from 'react';
2
+ import { IconButton } from '../IconButton';
3
+ import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/csr/MagnifyingGlass";
4
+ import { XIcon } from "@phosphor-icons/react/dist/csr/X";
5
+
6
+ interface SearchInputProps {
7
+ searchQuery: string;
8
+ setSearchQuery: (value: string) => void;
9
+ placeholder: string;
10
+ }
11
+
12
+ export function SearchInput({
13
+ searchQuery,
14
+ setSearchQuery,
15
+ placeholder,
16
+ }: SearchInputProps) {
17
+ const searchInputRef = useRef<HTMLInputElement>(null);
18
+
19
+ return (
20
+ <div className="relative">
21
+ <MagnifyingGlassIcon
22
+ className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-stone"
23
+ weight="bold"
24
+ />
25
+
26
+ <input
27
+ ref={searchInputRef}
28
+ type="text"
29
+ placeholder={placeholder}
30
+ value={searchQuery}
31
+ onChange={(e) => setSearchQuery(e.target.value)}
32
+ className="w-full pl-10 pr-10 py-3 text-sm border border-sand rounded-xl focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent"
33
+ />
34
+
35
+ {searchQuery && (
36
+ <IconButton
37
+ label="Clear search"
38
+ onClick={() => {
39
+ setSearchQuery('');
40
+ searchInputRef.current?.focus();
41
+ }}
42
+ className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-stone hover:text-charcoal"
43
+ >
44
+ <XIcon className="h-4 w-4" weight="bold" />
45
+ </IconButton>
46
+ )}
47
+ </div>
48
+ );
49
+ }
50
+
@@ -0,0 +1,9 @@
1
+ import { useMessagingContext } from '../providers/MessagingProvider';
2
+ import type { MessagingContextValue } from '../providers/MessagingProvider';
3
+
4
+ /**
5
+ * Hook to access messaging service and state
6
+ */
7
+ export const useMessaging = (): MessagingContextValue => {
8
+ return useMessagingContext();
9
+ };