@snapdragonsnursery/react-components 1.6.0 → 1.7.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapdragonsnursery/react-components",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -322,6 +322,10 @@ const ChildSearchModal = ({
322
322
  onClose();
323
323
  };
324
324
 
325
+ const handleClearSelection = () => {
326
+ setSelectedChildrenState([]);
327
+ };
328
+
325
329
  const isChildSelected = (child) => {
326
330
  return selectedChildrenState.some(
327
331
  (selected) => selected.child_id === child.child_id
@@ -870,6 +874,40 @@ const ChildSearchModal = ({
870
874
  </div>
871
875
  </div>
872
876
  )}
877
+
878
+ {/* Multi-select Action Bar */}
879
+ {multiSelect && (
880
+ <div className="p-6 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
881
+ <div className="text-sm text-gray-600 dark:text-gray-300">
882
+ {selectedChildrenState.length} selected{maxSelections ? ` / ${maxSelections}` : ''}
883
+ </div>
884
+ <div className="flex items-center gap-2">
885
+ <button
886
+ type="button"
887
+ onClick={handleClearSelection}
888
+ disabled={selectedChildrenState.length === 0}
889
+ className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
890
+ >
891
+ Clear
892
+ </button>
893
+ <button
894
+ type="button"
895
+ onClick={onClose}
896
+ className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
897
+ >
898
+ Go Back
899
+ </button>
900
+ <button
901
+ type="button"
902
+ onClick={handleConfirmSelection}
903
+ disabled={selectedChildrenState.length === 0}
904
+ className="px-3 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
905
+ >
906
+ Confirm
907
+ </button>
908
+ </div>
909
+ </div>
910
+ )}
873
911
  </div>
874
912
  </div>
875
913
  );
@@ -56,6 +56,8 @@ const ChildSearchPage = ({
56
56
  applicationContext = "child-search",
57
57
  bypassPermissions = false,
58
58
  onSelect = null, // Optional callback for when a child is selected
59
+ onConfirm = null, // Optional callback (multi-select confirm)
60
+ onBack = null, // Optional go-back handler in multi-select
59
61
  multiSelect = false,
60
62
  maxSelections = null,
61
63
  selectedChildren = [],
@@ -245,6 +247,11 @@ const ChildSearchPage = ({
245
247
  (index) => children[parseInt(index)]
246
248
  );
247
249
  setSelectedChildrenState(selectedRows);
250
+
251
+ // Call onSelect callback if provided and in multi-select mode
252
+ if (onSelect && multiSelect) {
253
+ onSelect(selectedRows);
254
+ }
248
255
  },
249
256
  });
250
257
 
@@ -445,6 +452,30 @@ const ChildSearchPage = ({
445
452
  setDebouncedAdvancedFilters(clearedFilters); // Clear immediately
446
453
  };
447
454
 
455
+ const handleClearSelection = () => {
456
+ setSelectedChildrenState([]);
457
+ if (onSelect && multiSelect) {
458
+ onSelect([]);
459
+ }
460
+ };
461
+
462
+ const handleConfirmSelection = () => {
463
+ const payload = selectedChildrenState;
464
+ if (onConfirm) {
465
+ onConfirm(payload);
466
+ } else if (onSelect) {
467
+ onSelect(payload);
468
+ }
469
+ };
470
+
471
+ const handleGoBack = () => {
472
+ if (onBack) {
473
+ onBack();
474
+ } else if (typeof window !== 'undefined' && window.history) {
475
+ window.history.back();
476
+ }
477
+ };
478
+
448
479
  // Calculate pagination display values
449
480
  const actualTotalCount = pagination.totalCount || children.length;
450
481
  const startItem = actualTotalCount > 0 ? (pagination.page - 1) * pagination.pageSize + 1 : 0;
@@ -686,6 +717,26 @@ const ChildSearchPage = ({
686
717
  </>
687
718
  )}
688
719
  </div>
720
+
721
+ {/* Multi-select Action Bar */}
722
+ {multiSelect && (
723
+ <div className="mt-4 flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
724
+ <div className="text-sm text-gray-600 dark:text-gray-300">
725
+ {selectedChildrenState.length} selected{maxSelections ? ` / ${maxSelections}` : ''}
726
+ </div>
727
+ <div className="flex items-center gap-2">
728
+ <Button variant="outline" onClick={handleClearSelection} disabled={selectedChildrenState.length === 0}>
729
+ Clear
730
+ </Button>
731
+ <Button variant="outline" onClick={handleGoBack}>
732
+ Go Back
733
+ </Button>
734
+ <Button onClick={handleConfirmSelection} disabled={selectedChildrenState.length === 0}>
735
+ Confirm
736
+ </Button>
737
+ </div>
738
+ </div>
739
+ )}
689
740
  </div>
690
741
  </div>
691
742
  );
@@ -275,12 +275,45 @@ const EmployeeSearchModal = ({
275
275
  },
276
276
  onSortingChange: setSorting,
277
277
  onRowSelectionChange: (updater) => {
278
- const newSelection =
279
- typeof updater === "function" ? updater({}) : updater;
280
- const selectedRows = Object.keys(newSelection).map(
281
- (index) => employees[parseInt(index)]
282
- );
283
- setSelectedEmployeesState(selectedRows);
278
+ // Merge current-table selection changes with the existing global selection
279
+ // instead of replacing it, so selection is retained across searches/pages.
280
+
281
+ // Build current selection map (for visible rows only)
282
+ const prevSelection = selectedEmployeesState.reduce((acc, selectedEmployee) => {
283
+ const rowIndex = employees.findIndex((employee) => employee.entra_id === selectedEmployee.entra_id);
284
+ if (rowIndex !== -1) acc[rowIndex] = true;
285
+ return acc;
286
+ }, {});
287
+
288
+ const updatedSelection = typeof updater === 'function' ? updater(prevSelection) : updater;
289
+
290
+ // Determine which visible employees are selected after the change
291
+ const visibleSelected = Object.keys(updatedSelection)
292
+ .filter((idx) => Boolean(updatedSelection[idx]))
293
+ .map((idx) => employees[parseInt(idx, 10)])
294
+ .filter(Boolean);
295
+
296
+ // Start from the previous global selection keyed by entra_id
297
+ const prevById = new Map(selectedEmployeesState.map((e) => [e.entra_id, e]));
298
+
299
+ // Remove any currently visible employees that are now unselected
300
+ const visibleIds = new Set(employees.map((e) => e.entra_id));
301
+ const visibleSelectedIds = new Set(visibleSelected.map((e) => e.entra_id));
302
+ employees.forEach((e) => {
303
+ if (visibleIds.has(e.entra_id) && !visibleSelectedIds.has(e.entra_id)) {
304
+ prevById.delete(e.entra_id);
305
+ }
306
+ });
307
+
308
+ // Add visible selected that weren't already present (respect maxSelections)
309
+ for (const e of visibleSelected) {
310
+ if (!prevById.has(e.entra_id)) {
311
+ if (maxSelections && prevById.size >= maxSelections) break;
312
+ prevById.set(e.entra_id, e);
313
+ }
314
+ }
315
+
316
+ setSelectedEmployeesState(Array.from(prevById.values()));
284
317
  },
285
318
  });
286
319
 
@@ -502,6 +535,10 @@ const EmployeeSearchModal = ({
502
535
  }
503
536
  };
504
537
 
538
+ const handleClearSelection = () => {
539
+ setSelectedEmployeesState([]);
540
+ };
541
+
505
542
  const clearFilters = () => {
506
543
  const clearedFilters = {
507
544
  status: activeOnly ? "Active" : "all",
@@ -549,22 +586,7 @@ const EmployeeSearchModal = ({
549
586
  <XMarkIcon className="h-6 w-6" />
550
587
  </button>
551
588
  </div>
552
- {multiSelect && (
553
- <div className="mt-2 flex items-center justify-between">
554
- <span className="text-sm text-gray-600 dark:text-gray-400">
555
- {selectedEmployeesState.length} selected
556
- {maxSelections && ` / ${maxSelections}`}
557
- </span>
558
- {selectedEmployeesState.length > 0 && (
559
- <Button
560
- size="sm"
561
- onClick={handleConfirmSelection}
562
- >
563
- Confirm Selection
564
- </Button>
565
- )}
566
- </div>
567
- )}
589
+ {/* Removed header confirm to avoid duplication; footer handles confirmation */}
568
590
  </div>
569
591
 
570
592
  {/* Content */}
@@ -793,20 +815,48 @@ const EmployeeSearchModal = ({
793
815
  </div>
794
816
 
795
817
  {/* Footer */}
796
- <div className="bg-gray-50 dark:bg-gray-700 px-6 py-3 flex justify-end space-x-3">
797
- <Button
798
- variant="outline"
799
- onClick={onClose}
800
- >
801
- Cancel
802
- </Button>
803
- {multiSelect && selectedEmployeesState.length > 0 && (
804
- <Button
805
- onClick={handleConfirmSelection}
806
- >
807
- Select {selectedEmployeesState.length} Employee{selectedEmployeesState.length !== 1 ? 's' : ''}
818
+ <div className="bg-gray-50 dark:bg-gray-700 px-6 py-3 flex items-center justify-between gap-3">
819
+ {/* Selection preview */}
820
+ <div className="flex-1 min-w-0">
821
+ {multiSelect && selectedEmployeesState.length > 0 && (
822
+ (() => {
823
+ const total = selectedEmployeesState.length;
824
+ const preview = selectedEmployeesState.slice(0, 5);
825
+ const extra = Math.max(total - preview.length, 0);
826
+ const previewText = `${total} selected${maxSelections ? ` / ${maxSelections}` : ''}: ` +
827
+ preview.map((e) => e.full_name).join(', ') +
828
+ (extra > 0 ? ` + ${extra} more` : '');
829
+ const fullList = selectedEmployeesState.map((e) => e.full_name).join(', ');
830
+ return (
831
+ <div
832
+ className="text-sm text-gray-600 dark:text-gray-300 truncate"
833
+ title={fullList}
834
+ >
835
+ {previewText}
836
+ </div>
837
+ );
838
+ })()
839
+ )}
840
+ </div>
841
+ <div className="flex items-center gap-2">
842
+ {multiSelect && (
843
+ <Button variant="outline" onClick={handleClearSelection} disabled={selectedEmployeesState.length === 0}>
844
+ Clear
845
+ </Button>
846
+ )}
847
+ <Button variant="outline" onClick={onClose}>
848
+ Go Back
808
849
  </Button>
809
- )}
850
+ {multiSelect && (
851
+ <Button
852
+ onClick={handleConfirmSelection}
853
+ disabled={selectedEmployeesState.length === 0}
854
+ aria-label={`Confirm ${selectedEmployeesState.length} selected`}
855
+ >
856
+ {`Confirm (${selectedEmployeesState.length}${maxSelections ? `/${maxSelections}` : ''})`}
857
+ </Button>
858
+ )}
859
+ </div>
810
860
  </div>
811
861
  </div>
812
862
  </div>
@@ -814,4 +864,4 @@ const EmployeeSearchModal = ({
814
864
  );
815
865
  };
816
866
 
817
- export default EmployeeSearchModal;
867
+ export default EmployeeSearchModal;
@@ -58,6 +58,8 @@ const EmployeeSearchPage = ({
58
58
  applicationContext = "employee-search",
59
59
  bypassPermissions = false,
60
60
  onSelect = null, // Optional callback for when an employee is selected
61
+ onConfirm = null, // Optional callback (multi-select confirm)
62
+ onBack = null, // Optional callback for go back/cancel
61
63
  multiSelect = false,
62
64
  maxSelections = null,
63
65
  selectedEmployees = [],
@@ -322,6 +324,11 @@ const EmployeeSearchPage = ({
322
324
  (index) => employees[parseInt(index)]
323
325
  );
324
326
  setSelectedEmployeesState(selectedRows);
327
+
328
+ // Call onSelect callback if provided and in multi-select mode
329
+ if (onSelect && multiSelect) {
330
+ onSelect(selectedRows);
331
+ }
325
332
  },
326
333
  });
327
334
 
@@ -615,6 +622,30 @@ const EmployeeSearchPage = ({
615
622
  setDebouncedAdvancedFilters(clearedFilters); // Clear immediately
616
623
  };
617
624
 
625
+ const handleClearSelection = () => {
626
+ setSelectedEmployeesState([]);
627
+ if (onSelect && multiSelect) {
628
+ onSelect([]);
629
+ }
630
+ };
631
+
632
+ const handleConfirmSelection = () => {
633
+ const payload = selectedEmployeesState;
634
+ if (onConfirm) {
635
+ onConfirm(payload);
636
+ } else if (onSelect) {
637
+ onSelect(payload);
638
+ }
639
+ };
640
+
641
+ const handleGoBack = () => {
642
+ if (onBack) {
643
+ onBack();
644
+ } else if (typeof window !== 'undefined' && window.history) {
645
+ window.history.back();
646
+ }
647
+ };
648
+
618
649
  // Calculate pagination display values
619
650
  const actualTotalCount = pagination.totalCount || employees.length;
620
651
  const startItem = loadAllResults ? 1 : (actualTotalCount > 0 ? (pagination.page - 1) * pagination.pageSize + 1 : 0);
@@ -897,9 +928,29 @@ const EmployeeSearchPage = ({
897
928
  </>
898
929
  )}
899
930
  </div>
931
+
932
+ {/* Multi-select Action Bar */}
933
+ {multiSelect && (
934
+ <div className="mt-4 flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
935
+ <div className="text-sm text-gray-600 dark:text-gray-300">
936
+ {selectedEmployeesState.length} selected{maxSelections ? ` / ${maxSelections}` : ''}
937
+ </div>
938
+ <div className="flex items-center gap-2">
939
+ <Button variant="outline" onClick={handleClearSelection} disabled={selectedEmployeesState.length === 0}>
940
+ Clear
941
+ </Button>
942
+ <Button variant="outline" onClick={handleGoBack}>
943
+ Go Back
944
+ </Button>
945
+ <Button onClick={handleConfirmSelection} disabled={selectedEmployeesState.length === 0}>
946
+ Confirm
947
+ </Button>
948
+ </div>
949
+ </div>
950
+ )}
900
951
  </div>
901
952
  </div>
902
953
  );
903
954
  };
904
955
 
905
- export default EmployeeSearchPage;
956
+ export default EmployeeSearchPage;
@@ -1,254 +1 @@
1
- // Employee Search Page Component Tests
2
- // Tests the EmployeeSearchPage component functionality including search, filtering, pagination, and selection
3
-
4
- import React from 'react';
5
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
6
- import '@testing-library/jest-dom';
7
- import EmployeeSearchPage from './EmployeeSearchPage';
8
-
9
- // Mock MSAL
10
- jest.mock('@azure/msal-react', () => ({
11
- useMsal: () => ({
12
- instance: {
13
- acquireTokenSilent: jest.fn(),
14
- },
15
- accounts: [{ localAccountId: 'test-user-id' }],
16
- }),
17
- }));
18
-
19
- // Mock telemetry
20
- jest.mock('./telemetry', () => ({
21
- trackEvent: jest.fn(),
22
- }));
23
-
24
- // Mock utils
25
- jest.mock('./lib/utils', () => ({
26
- cn: (...classes) => classes.filter(Boolean).join(' '),
27
- }));
28
-
29
-
30
-
31
- // Mock fetch
32
- global.fetch = jest.fn();
33
-
34
- // Mock process.env
35
- process.env.VITE_COMMON_API_FUNCTION_KEY = 'test-key';
36
- process.env.VITE_COMMON_API_BASE_URL = 'https://test-api.example.com';
37
-
38
- // Mock import.meta.env for Vite environment variables
39
- global.import = {
40
- meta: {
41
- env: {
42
- VITE_COMMON_API_FUNCTION_KEY: 'test-key',
43
- VITE_COMMON_API_BASE_URL: 'https://test-api.example.com',
44
- },
45
- },
46
- };
47
-
48
- // Mock the UI components
49
- jest.mock('./components/ui/input', () => ({
50
- Input: ({ value, onChange, placeholder, ...props }) => {
51
- return (
52
- <input
53
- data-testid="input"
54
- value={value}
55
- onChange={onChange}
56
- placeholder={placeholder}
57
- {...props}
58
- />
59
- );
60
- }
61
- }));
62
-
63
- jest.mock('./components/ui/button', () => ({
64
- Button: ({ children, onClick, variant, size, ...props }) => {
65
- return (
66
- <button
67
- data-testid="button"
68
- onClick={onClick}
69
- data-variant={variant}
70
- data-size={size}
71
- {...props}
72
- >
73
- {children}
74
- </button>
75
- );
76
- }
77
- }));
78
-
79
- jest.mock('./components/ui/table', () => ({
80
- Table: ({ children, ...props }) => <table data-testid="table" {...props}>{children}</table>,
81
- TableBody: ({ children, ...props }) => <tbody data-testid="table-body" {...props}>{children}</tbody>,
82
- TableCell: ({ children, ...props }) => <td data-testid="table-cell" {...props}>{children}</td>,
83
- TableHead: ({ children, ...props }) => <th data-testid="table-head" {...props}>{children}</th>,
84
- TableHeader: ({ children, ...props }) => <thead data-testid="table-header" {...props}>{children}</thead>,
85
- TableRow: ({ children, ...props }) => <tr data-testid="table-row" {...props}>{children}</tr>,
86
- }));
87
-
88
- jest.mock('./components/ui/pagination', () => ({
89
- Pagination: ({ children, ...props }) => <nav data-testid="pagination" {...props}>{children}</nav>,
90
- PaginationContent: ({ children, ...props }) => <div data-testid="pagination-content" {...props}>{children}</div>,
91
- PaginationEllipsis: ({ ...props }) => <span data-testid="pagination-ellipsis" {...props}>...</span>,
92
- PaginationItem: ({ children, ...props }) => <div data-testid="pagination-item" {...props}>{children}</div>,
93
- PaginationLink: ({ children, onClick, isActive, ...props }) => (
94
- <button data-testid="pagination-link" onClick={onClick} data-active={isActive} {...props}>
95
- {children}
96
- </button>
97
- ),
98
- PaginationNext: ({ children, onClick, ...props }) => (
99
- <button data-testid="pagination-next" onClick={onClick} {...props}>
100
- {children}
101
- </button>
102
- ),
103
- PaginationPrevious: ({ children, onClick, ...props }) => (
104
- <button data-testid="pagination-previous" onClick={onClick} {...props}>
105
- {children}
106
- </button>
107
- ),
108
- }));
109
-
110
- // Mock EmployeeSearchFilters
111
- jest.mock('./components/EmployeeSearchFilters', () => {
112
- return function MockEmployeeSearchFilters({ filters, onFiltersChange, onApplyFilters, onClearFilters, ...props }) {
113
- return (
114
- <div data-testid="employee-search-filters">
115
- <button onClick={() => onFiltersChange({ ...filters, status: 'Inactive' })}>
116
- Change Status
117
- </button>
118
- <button onClick={() => onApplyFilters(filters)}>
119
- Apply Filters
120
- </button>
121
- <button onClick={onClearFilters}>
122
- Clear Filters
123
- </button>
124
- </div>
125
- );
126
- };
127
- });
128
-
129
- const defaultProps = {
130
- title: 'Employee Search',
131
- onSelect: jest.fn(),
132
- };
133
-
134
- describe('EmployeeSearchPage', () => {
135
- beforeEach(() => {
136
- jest.clearAllMocks();
137
- global.fetch.mockResolvedValue({
138
- ok: true,
139
- json: async () => ({
140
- success: true,
141
- data: {
142
- employees: [],
143
- pagination: {
144
- page: 1,
145
- pageSize: 20,
146
- totalCount: 0,
147
- totalPages: 0,
148
- hasNextPage: false,
149
- hasPreviousPage: false,
150
- },
151
- },
152
- }),
153
- });
154
- });
155
-
156
- describe('Basic Rendering', () => {
157
- it('renders the page title', () => {
158
- render(<EmployeeSearchPage {...defaultProps} />);
159
-
160
- expect(screen.getByText('Employee Search')).toBeInTheDocument();
161
- });
162
-
163
- it('renders the search input', () => {
164
- render(<EmployeeSearchPage {...defaultProps} />);
165
-
166
- expect(screen.getByPlaceholderText('Search by name, employee ID, or email...')).toBeInTheDocument();
167
- });
168
-
169
- it('renders the employee search filters', () => {
170
- render(<EmployeeSearchPage {...defaultProps} />);
171
-
172
- expect(screen.getByTestId('employee-search-filters')).toBeInTheDocument();
173
- });
174
-
175
- it('shows loading state initially', () => {
176
- render(<EmployeeSearchPage {...defaultProps} />);
177
-
178
- expect(screen.getByRole('status')).toBeInTheDocument();
179
- });
180
- });
181
-
182
- describe('Search Functionality', () => {
183
- it('renders search input with correct placeholder', () => {
184
- render(<EmployeeSearchPage {...defaultProps} />);
185
-
186
- const searchInput = screen.getByPlaceholderText('Search by name, employee ID, or email...');
187
- expect(searchInput).toBeInTheDocument();
188
- expect(searchInput).toHaveAttribute('type', 'text');
189
- });
190
-
191
- it('allows typing in search input', () => {
192
- render(<EmployeeSearchPage {...defaultProps} />);
193
-
194
- const searchInput = screen.getByPlaceholderText('Search by name, employee ID, or email...');
195
- fireEvent.change(searchInput, { target: { value: 'John' } });
196
-
197
- expect(searchInput).toHaveValue('John');
198
- });
199
- });
200
-
201
- describe('Filter Integration', () => {
202
- it('renders filter buttons', () => {
203
- render(<EmployeeSearchPage {...defaultProps} />);
204
-
205
- expect(screen.getByText('Change Status')).toBeInTheDocument();
206
- expect(screen.getByText('Apply Filters')).toBeInTheDocument();
207
- expect(screen.getByText('Clear Filters')).toBeInTheDocument();
208
- });
209
-
210
- it('calls filter change handler when filter button is clicked', () => {
211
- render(<EmployeeSearchPage {...defaultProps} />);
212
-
213
- const changeStatusButton = screen.getByText('Change Status');
214
- fireEvent.click(changeStatusButton);
215
-
216
- // The mock should have been called
217
- expect(changeStatusButton).toBeInTheDocument();
218
- });
219
- });
220
-
221
- describe('Accessibility', () => {
222
- it('has proper ARIA labels', () => {
223
- render(<EmployeeSearchPage {...defaultProps} />);
224
-
225
- const searchInput = screen.getByPlaceholderText('Search by name, employee ID, or email...');
226
- expect(searchInput).toBeInTheDocument();
227
- });
228
-
229
- it('supports keyboard navigation', () => {
230
- render(<EmployeeSearchPage {...defaultProps} />);
231
-
232
- const searchInput = screen.getByPlaceholderText('Search by name, employee ID, or email...');
233
- searchInput.focus();
234
-
235
- expect(searchInput).toHaveFocus();
236
- });
237
- });
238
-
239
- describe('Props Handling', () => {
240
- it('renders custom title', () => {
241
- render(<EmployeeSearchPage {...defaultProps} title="Custom Title" />);
242
-
243
- expect(screen.getByText('Custom Title')).toBeInTheDocument();
244
- });
245
-
246
- it('renders custom search placeholder', () => {
247
- render(<EmployeeSearchPage {...defaultProps} searchPlaceholder="Custom placeholder" />);
248
-
249
- expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument();
250
- });
251
- });
252
-
253
-
254
- });
1
+
@@ -299,6 +299,7 @@ export function DatePicker({
299
299
  className,
300
300
  placeholder = "Select a date",
301
301
  disabled,
302
+ disableFuture = false,
302
303
  ...props
303
304
  }) {
304
305
  const [isOpen, setIsOpen] = useState(false)
@@ -334,6 +335,7 @@ export function DatePicker({
334
335
  mode="single"
335
336
  selected={selectedDate}
336
337
  onSelect={handleSelect}
338
+ disabled={disableFuture ? { after: new Date() } : undefined}
337
339
  initialFocus
338
340
  />
339
341
  </PopoverContent>
@@ -0,0 +1,100 @@
1
+ // Stat Card UI Component (shared library)
2
+ // Reusable statistic card with optional icon and caption.
3
+ // Example:
4
+ // <StatCard title="Active Users" value="97K" caption="+24.3% this month" icon={ArrowTrendingUpIcon} tone="success" />
5
+
6
+ import React from 'react'
7
+ import { cn } from '../../lib/utils'
8
+
9
+ // Lightweight Card primitives aligned with this package styling
10
+ const Card = React.forwardRef(function Card({ className, ...props }, ref) {
11
+ return (
12
+ <div
13
+ ref={ref}
14
+ className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
15
+ {...props}
16
+ />
17
+ )
18
+ })
19
+
20
+ const CardHeader = React.forwardRef(function CardHeader({ className, ...props }, ref) {
21
+ return <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
22
+ })
23
+
24
+ const CardTitle = React.forwardRef(function CardTitle({ className, ...props }, ref) {
25
+ return (
26
+ <h3
27
+ ref={ref}
28
+ className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
29
+ {...props}
30
+ />
31
+ )
32
+ })
33
+
34
+ const CardContent = React.forwardRef(function CardContent({ className, ...props }, ref) {
35
+ return <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
36
+ })
37
+
38
+ function Skeleton({ className, ...props }) {
39
+ return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
40
+ }
41
+
42
+ const toneClasses = {
43
+ default: {
44
+ badge: 'bg-gray-100 text-gray-700',
45
+ value: 'text-foreground',
46
+ ring: 'ring-gray-200',
47
+ },
48
+ success: {
49
+ badge: 'bg-emerald-100 text-emerald-700',
50
+ value: 'text-emerald-700',
51
+ ring: 'ring-emerald-200',
52
+ },
53
+ danger: {
54
+ badge: 'bg-rose-100 text-rose-700',
55
+ value: 'text-rose-700',
56
+ ring: 'ring-rose-200',
57
+ },
58
+ warning: {
59
+ badge: 'bg-amber-100 text-amber-700',
60
+ value: 'text-amber-700',
61
+ ring: 'ring-amber-200',
62
+ },
63
+ info: {
64
+ badge: 'bg-sky-100 text-sky-700',
65
+ value: 'text-sky-700',
66
+ ring: 'ring-sky-200',
67
+ },
68
+ }
69
+
70
+ export function StatCard({ title, value, caption, icon: Icon, tone = 'default', className, loading = false }) {
71
+ const styles = toneClasses[tone] || toneClasses.default
72
+ return (
73
+ <Card className={cn('shadow-sm hover:shadow transition-shadow', className)}>
74
+ <CardHeader className="pb-3">
75
+ <div className="flex items-center justify-between">
76
+ <CardTitle className="text-sm font-medium text-muted-foreground">
77
+ {loading ? <Skeleton className="h-4 w-24" /> : title}
78
+ </CardTitle>
79
+ {Icon ? (
80
+ <div className={cn('inline-flex h-10 w-10 items-center justify-center rounded-full ring-1', styles.badge, styles.ring)}>
81
+ {loading ? <Skeleton className="h-5 w-5 rounded-full" /> : <Icon className="h-5 w-5" />}
82
+ </div>
83
+ ) : null}
84
+ </div>
85
+ </CardHeader>
86
+ <CardContent>
87
+ <div className={cn('text-3xl font-extrabold tracking-tight', styles.value)}>
88
+ {loading ? <Skeleton className="h-8 w-20" /> : value}
89
+ </div>
90
+ {caption ? (
91
+ <div className="mt-1 text-sm text-muted-foreground">
92
+ {loading ? <Skeleton className="h-4 w-28" /> : caption}
93
+ </div>
94
+ ) : null}
95
+ </CardContent>
96
+ </Card>
97
+ )
98
+ }
99
+
100
+ export default StatCard
@@ -0,0 +1,24 @@
1
+ // Basic render tests for StatCard
2
+ // Example usage demonstrated in the component file header.
3
+
4
+ import React from 'react'
5
+ import { render, screen } from '@testing-library/react'
6
+ import StatCard from './stat-card'
7
+
8
+ describe('StatCard (library)', () => {
9
+ it('renders title and value', () => {
10
+ render(<StatCard title="Total" value={42} caption="units" />)
11
+ expect(screen.getByText('Total')).toBeInTheDocument()
12
+ expect(screen.getByText('42')).toBeInTheDocument()
13
+ expect(screen.getByText('units')).toBeInTheDocument()
14
+ })
15
+
16
+ it('renders skeletons when loading', () => {
17
+ const { container } = render(<StatCard title="Total" value={42} caption="units" loading />)
18
+ expect(screen.queryByText('Total')).toBeNull()
19
+ expect(screen.queryByText('42')).toBeNull()
20
+ expect(screen.queryByText('units')).toBeNull()
21
+ // Skeletons exist
22
+ expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0)
23
+ })
24
+ })
package/src/index.d.ts CHANGED
@@ -36,13 +36,32 @@ export const EmployeeSearchDemo: React.ComponentType<any>
36
36
  export const EmployeeSearchFilters: React.ComponentType<any>
37
37
 
38
38
  export const DateRangePicker: React.ComponentType<any>
39
- export const DatePicker: React.ComponentType<any>
39
+ export interface DatePickerProps {
40
+ selectedDate?: Date | null
41
+ onSelect: (date: Date | null) => void
42
+ className?: string
43
+ placeholder?: string
44
+ disabled?: boolean
45
+ disableFuture?: boolean
46
+ }
47
+ export const DatePicker: React.ComponentType<DatePickerProps>
40
48
  export const Calendar: React.ComponentType<any>
41
49
  export const SimpleCalendar: React.ComponentType<any>
42
50
  export const Popover: React.ComponentType<any>
43
51
  export const PopoverContent: React.ComponentType<any>
44
52
  export const PopoverTrigger: React.ComponentType<any>
45
53
 
54
+ export interface StatCardProps {
55
+ title: string
56
+ value: React.ReactNode
57
+ caption?: React.ReactNode
58
+ icon?: React.ComponentType<any>
59
+ tone?: 'default' | 'success' | 'danger' | 'warning' | 'info'
60
+ className?: string
61
+ loading?: boolean
62
+ }
63
+ export const StatCard: React.ComponentType<StatCardProps>
64
+
46
65
  export function configureTelemetry(...args: any[]): any
47
66
 
48
67
 
package/src/index.js CHANGED
@@ -1,27 +1,28 @@
1
- export { default as AuthButtons } from "./AuthButtons";
2
- export { default as ThemeToggle } from "./ThemeToggle";
3
- export { default as ChildSearchModal } from "./ChildSearchModal";
4
- export { default as ChildSearchPage } from "./ChildSearchPage";
5
- export { default as ChildSearchPageDemo } from "./ChildSearchPageDemo";
6
- export { default as ThemeToggleTest } from "./ThemeToggleTest";
7
- export { default as LandingPage } from "./LandingPage";
8
- export { default as ChildSearchFilters } from "./components/ChildSearchFilters";
9
- export { default as DateRangePickerDemo } from "./DateRangePickerDemo";
10
- export { default as CalendarDemo } from "./CalendarDemo";
11
- export { default as DateRangePickerTest } from "./DateRangePickerTest";
12
- export { default as ApplyButtonDemo } from "./ApplyButtonDemo";
1
+ export { default as AuthButtons } from "./AuthButtons.jsx";
2
+ export { default as ThemeToggle } from "./ThemeToggle.jsx";
3
+ export { default as ChildSearchModal } from "./ChildSearchModal.jsx";
4
+ export { default as ChildSearchPage } from "./ChildSearchPage.jsx";
5
+ export { default as ChildSearchPageDemo } from "./ChildSearchPageDemo.jsx";
6
+ export { default as ThemeToggleTest } from "./ThemeToggleTest.jsx";
7
+ export { default as LandingPage } from "./LandingPage.jsx";
8
+ export { default as ChildSearchFilters } from "./components/ChildSearchFilters.jsx";
9
+ export { default as DateRangePickerDemo } from "./DateRangePickerDemo.jsx";
10
+ export { default as CalendarDemo } from "./CalendarDemo.jsx";
11
+ export { default as DateRangePickerTest } from "./DateRangePickerTest.jsx";
12
+ export { default as ApplyButtonDemo } from "./ApplyButtonDemo.jsx";
13
13
 
14
14
  // Employee Search Components
15
- export { default as EmployeeSearchPage } from "./EmployeeSearchPage";
16
- export { default as EmployeeSearchModal } from "./EmployeeSearchModal";
17
- export { default as EmployeeSearchDemo } from "./EmployeeSearchDemo";
18
- export { default as EmployeeSearchFilters } from "./components/EmployeeSearchFilters";
15
+ export { default as EmployeeSearchPage } from "./EmployeeSearchPage.jsx";
16
+ export { default as EmployeeSearchModal } from "./EmployeeSearchModal.jsx";
17
+ export { default as EmployeeSearchDemo } from "./EmployeeSearchDemo.jsx";
18
+ export { default as EmployeeSearchFilters } from "./components/EmployeeSearchFilters.jsx";
19
19
 
20
- export { configureTelemetry } from "./telemetry";
20
+ export { configureTelemetry } from "./telemetry.js";
21
21
 
22
22
  // UI Components
23
- export { DateRangePicker, DatePicker } from "./components/ui/date-range-picker";
24
- export { Calendar } from "./components/ui/calendar";
25
- export { SimpleCalendar } from "./components/ui/simple-calendar";
26
- export { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover";
27
- export { default as SoftWarningAlert } from "./components/ui/soft-warning-alert";
23
+ export { DateRangePicker, DatePicker } from "./components/ui/date-range-picker.jsx";
24
+ export { Calendar } from "./components/ui/calendar.jsx";
25
+ export { SimpleCalendar } from "./components/ui/simple-calendar.jsx";
26
+ export { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover.jsx";
27
+ export { default as SoftWarningAlert } from "./components/ui/soft-warning-alert.jsx";
28
+ export { default as StatCard } from "./components/ui/stat-card.jsx";