universal-adaptive-bars 0.0.4 → 0.0.5
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/README.md +8 -1
- package/package.json +1 -1
- package/src/lib/SmartBarChart.tsx +168 -54
- package/src/lib/__tests__/Navigation.test.tsx +40 -0
- package/src/lib/components/Bar.tsx +2 -2
package/README.md
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
A highly customizable, interactive, and drill-down capable Bar Chart library for **React** and **React Native**.
|
|
4
4
|
|
|
5
5
|

|
|
6
|
-

|
|
7
|
+
|
|
8
|
+
📖 **Read the Story**: [Universal Adaptive Bars: The Smart Cross-Platform Charting Library You’ve Been Waiting For](https://sammeddoshi.medium.com/universal-adaptive-bars-the-smart-cross-platform-charting-library-youve-been-waiting-for-80501b0c0e3b)
|
|
9
|
+
|
|
10
|
+
## New in v0.0.5 🚀
|
|
11
|
+
- **Calendar Jump**: Selecting a date in the calendar now "jumps" to that date (scrolling the view) instead of filtering data, preserving your ability to navigate back and forth.
|
|
12
|
+
- **Smart Stacked Radius**: Stacked bars now intelligently apply rounded corners only to the top and bottom segments for a polished UI.
|
|
13
|
+
- **Improved Alignment**: Perfect X-axis alignment for both standard and stacked bars.
|
|
7
14
|
|
|
8
15
|
## Features
|
|
9
16
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useMemo, useState, useEffect } from 'react';
|
|
2
2
|
import { scaleBand, scaleLinear } from 'd3-scale';
|
|
3
|
-
import { getYear,
|
|
3
|
+
import { getYear, format, endOfWeek, eachWeekOfInterval, endOfMonth } from 'date-fns';
|
|
4
4
|
import { useChartData } from './hooks/useChartData';
|
|
5
5
|
import { Bar } from './components/Bar';
|
|
6
6
|
import type { SmartBarChartProps, DataPoint } from './types';
|
|
@@ -10,7 +10,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
10
10
|
data,
|
|
11
11
|
view = 'month',
|
|
12
12
|
variant = 'default',
|
|
13
|
-
dataKeys = { date: 'date', value: 'value', label: 'label' },
|
|
13
|
+
dataKeys: providedDataKeys = { date: 'date', value: 'value', label: 'label' },
|
|
14
14
|
|
|
15
15
|
geminiConfig,
|
|
16
16
|
colors,
|
|
@@ -21,29 +21,39 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
21
21
|
className = '',
|
|
22
22
|
theme
|
|
23
23
|
}) => {
|
|
24
|
+
// Helper for rounded paths
|
|
25
|
+
const getRoundedPath = (x: number, y: number, w: number, h: number, r: number, corners: { tl: boolean, tr: boolean, bl: boolean, br: boolean }) => {
|
|
26
|
+
const tl = corners.tl ? r : 0;
|
|
27
|
+
const tr = corners.tr ? r : 0;
|
|
28
|
+
const bl = corners.bl ? r : 0;
|
|
29
|
+
const br = corners.br ? r : 0;
|
|
30
|
+
|
|
31
|
+
return `
|
|
32
|
+
M ${x + tl} ${y}
|
|
33
|
+
L ${x + w - tr} ${y}
|
|
34
|
+
Q ${x + w} ${y} ${x + w} ${y + tr}
|
|
35
|
+
L ${x + w} ${y + h - br}
|
|
36
|
+
Q ${x + w} ${y + h} ${x + w - br} ${y + h}
|
|
37
|
+
L ${x + bl} ${y + h}
|
|
38
|
+
Q ${x} ${y + h} ${x} ${y + h - bl}
|
|
39
|
+
L ${x} ${y + tl}
|
|
40
|
+
Q ${x} ${y} ${x + tl} ${y}
|
|
41
|
+
Z
|
|
42
|
+
`;
|
|
43
|
+
};
|
|
24
44
|
// Calendar / Filter State
|
|
25
45
|
const [filterDate, setFilterDate] = useState<{ year: number | null, month: number | null, weekStartDate: Date | null }>({ year: null, month: null, weekStartDate: null });
|
|
46
|
+
const [tempFilterDate, setTempFilterDate] = useState<{ year: number | null, month: number | null, weekStartDate: Date | null }>({ year: null, month: null, weekStartDate: null });
|
|
26
47
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
27
48
|
const [pickerMode, setPickerMode] = useState<'year' | 'month' | 'week'>('year');
|
|
28
49
|
|
|
29
|
-
|
|
30
|
-
const filteredRawData = useMemo(() => {
|
|
31
|
-
if (!filterDate.year) return data;
|
|
32
|
-
return data.filter(d => {
|
|
33
|
-
const dateStr = d[dataKeys.date] as string;
|
|
34
|
-
const date = new Date(dateStr);
|
|
35
|
-
if (getYear(date) !== filterDate.year) return false;
|
|
36
|
-
if (filterDate.month !== null && getMonth(date) !== filterDate.month) return false;
|
|
37
|
-
|
|
38
|
-
if (filterDate.weekStartDate) {
|
|
39
|
-
const start = filterDate.weekStartDate;
|
|
40
|
-
const end = endOfWeek(start);
|
|
41
|
-
return isWithinInterval(date, { start, end });
|
|
42
|
-
}
|
|
50
|
+
const dataKeys = useMemo(() => providedDataKeys, [providedDataKeys.date, providedDataKeys.label, Array.isArray(providedDataKeys.value) ? providedDataKeys.value.join(',') : providedDataKeys.value]);
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
// Filter Raw Data BEFORE hooks
|
|
53
|
+
// REF_CHANGE: We no longer filter the data here. We pass full data to useChartData
|
|
54
|
+
// and use the filterDate only to determining the initial Window Offset.
|
|
55
|
+
// However, we might still want to filter if the user explicitly wants to "Restrict" logic,
|
|
56
|
+
// but the request is to allow navigation. So we pass `data` directly.
|
|
47
57
|
|
|
48
58
|
// Derived Years for Picker
|
|
49
59
|
const availableYears = useMemo(() => {
|
|
@@ -51,7 +61,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
51
61
|
return Array.from(years).sort((a, b) => b - a);
|
|
52
62
|
}, [data, dataKeys.date]);
|
|
53
63
|
|
|
54
|
-
const fullChartData = useChartData({ data
|
|
64
|
+
const fullChartData = useChartData({ data, view, dataKeys, colors });
|
|
55
65
|
const [activeItem, setActiveItem] = useState<DataPoint | null>(null);
|
|
56
66
|
const [predictions, setPredictions] = useState<DataPoint[]>([]);
|
|
57
67
|
const [isPredicting, setIsPredicting] = useState(false);
|
|
@@ -86,35 +96,98 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
86
96
|
|
|
87
97
|
}, [view]);
|
|
88
98
|
|
|
89
|
-
//
|
|
99
|
+
// Watch for filterDate changes to update Window Offset (Navigation / Jump)
|
|
90
100
|
useEffect(() => {
|
|
91
|
-
|
|
101
|
+
if (!filterDate.year && !filterDate.weekStartDate && filterDate.month === null) return;
|
|
102
|
+
|
|
92
103
|
setPredictions([]);
|
|
93
|
-
|
|
104
|
+
|
|
105
|
+
// Find the index of the first item that matches our filter
|
|
106
|
+
// We need to look at 'fullChartData', but wait, 'fullChartData' depends on 'view'.
|
|
107
|
+
// If 'view' just changed, 'fullChartData' will update in next render.
|
|
108
|
+
// We might need to delay this check or simply calculate it.
|
|
109
|
+
// For now, let's assume 'fullChartData' is updated or will be.
|
|
110
|
+
// Actually, if we change View AND Filter same time, we need to be careful.
|
|
111
|
+
|
|
112
|
+
// Let's defer this logic slightly or assume the user cycle involves a render.
|
|
113
|
+
// Typically, we want to find the first data point that starts at or after our filter date.
|
|
114
|
+
|
|
115
|
+
let targetDate: Date | null = null;
|
|
116
|
+
if (filterDate.weekStartDate) targetDate = filterDate.weekStartDate;
|
|
117
|
+
else if (filterDate.month !== null && filterDate.year) targetDate = new Date(filterDate.year, filterDate.month, 1);
|
|
118
|
+
else if (filterDate.year) targetDate = new Date(filterDate.year, 0, 1);
|
|
119
|
+
|
|
120
|
+
if (targetDate && fullChartData.length > 0) {
|
|
121
|
+
// Find index
|
|
122
|
+
const idx = fullChartData.findIndex(d => d.date.getTime() >= targetDate!.getTime());
|
|
123
|
+
if (idx !== -1) {
|
|
124
|
+
// We want this 'idx' to be the first visible item (start of window).
|
|
125
|
+
// start = total - vis - offset
|
|
126
|
+
// idx = total - vis - offset
|
|
127
|
+
// offset = total - vis - idx
|
|
128
|
+
|
|
129
|
+
const total = fullChartData.length;
|
|
130
|
+
const newOffset = Math.max(0, total - VISIBLE_COUNT - idx);
|
|
131
|
+
setWindowOffset(newOffset);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}, [filterDate, fullChartData, VISIBLE_COUNT]); // Depend on fullChartData so it runs after data update
|
|
94
135
|
|
|
95
136
|
const handleYearSelect = (year: number) => {
|
|
96
|
-
|
|
137
|
+
setTempFilterDate({ year, month: null, weekStartDate: null });
|
|
97
138
|
setPickerMode('month');
|
|
98
|
-
onViewChange?.('month');
|
|
99
139
|
};
|
|
100
140
|
|
|
101
141
|
const handleMonthSelect = (monthIndex: number) => {
|
|
102
|
-
|
|
142
|
+
setTempFilterDate(prev => ({ ...prev, month: monthIndex, weekStartDate: null }));
|
|
103
143
|
setPickerMode('week');
|
|
104
|
-
onViewChange?.('week');
|
|
105
144
|
};
|
|
106
145
|
|
|
107
146
|
const handleWeekSelect = (weekStart: Date) => {
|
|
108
|
-
|
|
147
|
+
setTempFilterDate(prev => ({ ...prev, weekStartDate: weekStart }));
|
|
148
|
+
// Logic change: Don't auto-close. Let them confirm.
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const handleConfirm = () => {
|
|
152
|
+
setFilterDate(tempFilterDate);
|
|
109
153
|
setIsPickerOpen(false);
|
|
110
|
-
|
|
154
|
+
|
|
155
|
+
// Auto-update view based on depth
|
|
156
|
+
let targetView: 'day' | 'week' | 'month' | undefined = undefined;
|
|
157
|
+
if (tempFilterDate.weekStartDate) targetView = 'day';
|
|
158
|
+
else if (tempFilterDate.month !== null) targetView = 'week';
|
|
159
|
+
else if (tempFilterDate.year !== null) targetView = 'month';
|
|
160
|
+
|
|
161
|
+
if (targetView) {
|
|
162
|
+
onViewChange?.(targetView);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Calculate Offset to Jump To
|
|
166
|
+
// data is sorted ascending. windowOffset is from the END.
|
|
167
|
+
// windowOffset = 0 => shows [ ... , last ]
|
|
168
|
+
// start = totalLen - VISIBLE_COUNT - windowOffset
|
|
169
|
+
// We want 'start' to be the index of our target date.
|
|
170
|
+
// So windowOffset = totalLen - VISIBLE_COUNT - targetIndex
|
|
171
|
+
|
|
172
|
+
// Need to find targetIndex in *re-calculated* fullChartData for the NEW view.
|
|
173
|
+
// Since we can't wait for 'fullChartData' to update in this render cycle effectively
|
|
174
|
+
// (unless we wait for effect), we might need to rely on the fact that useChartData is fast
|
|
175
|
+
// or recalculate finding index in an effect.
|
|
176
|
+
|
|
177
|
+
// BETTER APPROACH:
|
|
178
|
+
// We just updated 'filterDate'. We can add an Effect that watches 'filterDate'
|
|
179
|
+
// and updates 'windowOffset' when it changes.
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const handleOpenPicker = () => {
|
|
183
|
+
setTempFilterDate(filterDate); // Sync temp with current
|
|
184
|
+
setPickerMode(filterDate.year ? (filterDate.month !== null ? 'week' : 'month') : 'year');
|
|
185
|
+
setIsPickerOpen(true);
|
|
111
186
|
};
|
|
112
187
|
|
|
113
188
|
const clearFilter = () => {
|
|
114
|
-
|
|
189
|
+
setTempFilterDate({ year: null, month: null, weekStartDate: null });
|
|
115
190
|
setPickerMode('year');
|
|
116
|
-
setIsPickerOpen(false);
|
|
117
|
-
onViewChange?.('month'); // Reset View
|
|
118
191
|
};
|
|
119
192
|
|
|
120
193
|
// Data Slicing
|
|
@@ -190,7 +263,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
190
263
|
const axisSize = theme?.axis?.fontSize || 10;
|
|
191
264
|
|
|
192
265
|
return (
|
|
193
|
-
<div className={`smart-bar-chart-wrapper ${className}`} style={{ width, display: 'flex', flexDirection: 'column', gap: 10, fontFamily: 'sans-serif', background: bg, padding: 16, borderRadius: 12 }}>
|
|
266
|
+
<div className={`smart-bar-chart-wrapper ${className}`} style={{ width, display: 'flex', flexDirection: 'column', gap: 10, fontFamily: 'sans-serif', background: bg, padding: 16, borderRadius: 12, position: 'relative' }}>
|
|
194
267
|
|
|
195
268
|
{/* Legend / Info / Predict Header */}
|
|
196
269
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: 40 }}>
|
|
@@ -218,7 +291,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
218
291
|
|
|
219
292
|
{/* Calendar Button */}
|
|
220
293
|
<button
|
|
221
|
-
onClick={() => setIsPickerOpen(
|
|
294
|
+
onClick={() => isPickerOpen ? setIsPickerOpen(false) : handleOpenPicker()}
|
|
222
295
|
style={{
|
|
223
296
|
marginLeft: 10, padding: 6, cursor: 'pointer', background: '#fff', border: '1px solid #ddd', borderRadius: 4,
|
|
224
297
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
|
@@ -235,22 +308,30 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
235
308
|
{/* Calendar Picker Popup */}
|
|
236
309
|
{isPickerOpen && (
|
|
237
310
|
<div style={{
|
|
238
|
-
position: 'absolute', top: 50,
|
|
311
|
+
position: 'absolute', top: 60, left: '50%', transform: 'translateX(-50%)', width: 260, background: '#fff',
|
|
239
312
|
border: '1px solid #e0e0e0', borderRadius: 12,
|
|
240
|
-
boxShadow: '0 10px 25px rgba(0,0,0,0.
|
|
313
|
+
boxShadow: '0 10px 25px rgba(0,0,0,0.15)', zIndex: 30, padding: 16,
|
|
241
314
|
fontFamily: 'sans-serif'
|
|
242
315
|
}}>
|
|
243
316
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, alignItems: 'center' }}>
|
|
244
317
|
{pickerMode !== 'year' && (
|
|
245
318
|
<button
|
|
246
|
-
onClick={() =>
|
|
319
|
+
onClick={() => {
|
|
320
|
+
if (pickerMode === 'week') {
|
|
321
|
+
setPickerMode('month');
|
|
322
|
+
setTempFilterDate(prev => ({ ...prev, weekStartDate: null }));
|
|
323
|
+
} else {
|
|
324
|
+
setPickerMode('year');
|
|
325
|
+
setTempFilterDate(prev => ({ ...prev, month: null, weekStartDate: null }));
|
|
326
|
+
}
|
|
327
|
+
}}
|
|
247
328
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: '#6366f1', fontWeight: 600 }}
|
|
248
329
|
>
|
|
249
330
|
< Back
|
|
250
331
|
</button>
|
|
251
332
|
)}
|
|
252
333
|
<div style={{ fontSize: 14, fontWeight: 'bold', color: '#1f2937', flex: 1, textAlign: pickerMode !== 'year' ? 'center' : 'left' }}>
|
|
253
|
-
{pickerMode === 'year' ? 'Select Year' : pickerMode === 'month' ? `${
|
|
334
|
+
{pickerMode === 'year' ? 'Select Year' : pickerMode === 'month' ? `${tempFilterDate.year}` : `${format(new Date(tempFilterDate.year!, tempFilterDate.month!, 1), 'MMM yyyy')}`}
|
|
254
335
|
</div>
|
|
255
336
|
<button
|
|
256
337
|
onClick={() => { clearFilter(); }}
|
|
@@ -269,10 +350,10 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
269
350
|
style={{
|
|
270
351
|
padding: '8px 4px', fontSize: 13,
|
|
271
352
|
border: '1px solid',
|
|
272
|
-
borderColor:
|
|
353
|
+
borderColor: tempFilterDate.year === year ? '#6366f1' : '#eee',
|
|
273
354
|
borderRadius: 6,
|
|
274
|
-
background:
|
|
275
|
-
color:
|
|
355
|
+
background: tempFilterDate.year === year ? '#e0e7ff' : '#fff',
|
|
356
|
+
color: tempFilterDate.year === year ? '#4338ca' : '#374151',
|
|
276
357
|
cursor: 'pointer',
|
|
277
358
|
transition: 'all 0.2s'
|
|
278
359
|
}}
|
|
@@ -289,10 +370,10 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
289
370
|
style={{
|
|
290
371
|
padding: '8px 4px', fontSize: 13,
|
|
291
372
|
border: '1px solid',
|
|
292
|
-
borderColor:
|
|
373
|
+
borderColor: tempFilterDate.month === i ? '#6366f1' : '#eee',
|
|
293
374
|
borderRadius: 6,
|
|
294
|
-
background:
|
|
295
|
-
color:
|
|
375
|
+
background: tempFilterDate.month === i ? '#e0e7ff' : '#fff',
|
|
376
|
+
color: tempFilterDate.month === i ? '#4338ca' : '#374151',
|
|
296
377
|
cursor: 'pointer',
|
|
297
378
|
transition: 'all 0.2s'
|
|
298
379
|
}}
|
|
@@ -301,10 +382,10 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
301
382
|
</button>
|
|
302
383
|
))
|
|
303
384
|
)}
|
|
304
|
-
{pickerMode === 'week' &&
|
|
385
|
+
{pickerMode === 'week' && tempFilterDate.year && tempFilterDate.month !== null && (
|
|
305
386
|
eachWeekOfInterval({
|
|
306
|
-
start: new Date(
|
|
307
|
-
end: endOfMonth(new Date(
|
|
387
|
+
start: new Date(tempFilterDate.year, tempFilterDate.month, 1),
|
|
388
|
+
end: endOfMonth(new Date(tempFilterDate.year, tempFilterDate.month, 1))
|
|
308
389
|
}).map((weekStart, i) => {
|
|
309
390
|
// Only show weeks that actually overlap with the month meaningfully?
|
|
310
391
|
// eachWeekOfInterval gives standard weeks.
|
|
@@ -317,7 +398,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
317
398
|
style={{
|
|
318
399
|
padding: '8px 12px', fontSize: 13,
|
|
319
400
|
border: '1px solid',
|
|
320
|
-
borderColor: '#eee',
|
|
401
|
+
borderColor: tempFilterDate.weekStartDate?.getTime() === weekStart.getTime() ? '#6366f1' : '#eee',
|
|
321
402
|
borderRadius: 6,
|
|
322
403
|
background: '#fff',
|
|
323
404
|
color: '#374151',
|
|
@@ -332,6 +413,27 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
332
413
|
})
|
|
333
414
|
)}
|
|
334
415
|
</div>
|
|
416
|
+
|
|
417
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
|
418
|
+
<button
|
|
419
|
+
onClick={handleConfirm}
|
|
420
|
+
style={{
|
|
421
|
+
flex: 1, padding: '8px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: 6,
|
|
422
|
+
fontSize: 13, fontWeight: 'bold', cursor: 'pointer'
|
|
423
|
+
}}
|
|
424
|
+
>
|
|
425
|
+
Confirm
|
|
426
|
+
</button>
|
|
427
|
+
<button
|
|
428
|
+
onClick={() => setIsPickerOpen(false)}
|
|
429
|
+
style={{
|
|
430
|
+
flex: 1, padding: '8px', background: '#f3f4f6', color: '#374151', border: 'none', borderRadius: 6,
|
|
431
|
+
fontSize: 13, fontWeight: 'bold', cursor: 'pointer'
|
|
432
|
+
}}
|
|
433
|
+
>
|
|
434
|
+
Cancel
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
335
437
|
</div>
|
|
336
438
|
)}
|
|
337
439
|
</div>
|
|
@@ -429,15 +531,27 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
|
|
|
429
531
|
{d.stackedValues.map((stack, i) => {
|
|
430
532
|
const segmentHeight = Math.abs(yScale(stack.value) - yScale(0));
|
|
431
533
|
const segmentY = currentY - segmentHeight;
|
|
534
|
+
|
|
535
|
+
// Determine corners
|
|
536
|
+
const isTop = i === d.stackedValues!.length - 1;
|
|
537
|
+
const isBottom = i === 0;
|
|
538
|
+
|
|
539
|
+
// Only top-most segment gets top radius.
|
|
540
|
+
// Only bottom-most segment gets bottom radius.
|
|
541
|
+
const corners = {
|
|
542
|
+
tl: isTop,
|
|
543
|
+
tr: isTop,
|
|
544
|
+
bl: isBottom,
|
|
545
|
+
br: isBottom
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const pathD = getRoundedPath(x, segmentY, barWidth, segmentHeight, barRadius, corners);
|
|
549
|
+
|
|
432
550
|
const rect = (
|
|
433
|
-
<
|
|
551
|
+
<path
|
|
434
552
|
key={`${d.id}-${i}`}
|
|
435
|
-
|
|
436
|
-
y={segmentY}
|
|
437
|
-
width={barWidth}
|
|
438
|
-
height={segmentHeight}
|
|
553
|
+
d={pathD}
|
|
439
554
|
fill={stack.color}
|
|
440
|
-
rx={barRadius}
|
|
441
555
|
opacity={barOpacity}
|
|
442
556
|
stroke={isSelected ? '#fff' : 'none'}
|
|
443
557
|
strokeWidth={isSelected ? 1 : 0}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { SmartBarChart } from '../SmartBarChart';
|
|
4
|
+
|
|
5
|
+
describe('SmartBarChart Navigation', () => {
|
|
6
|
+
const mockData = Array.from({ length: 24 }, (_, i) => {
|
|
7
|
+
const year = 2023 + Math.floor(i / 12);
|
|
8
|
+
const month = (i % 12) + 1;
|
|
9
|
+
return {
|
|
10
|
+
date: `${year}-${String(month).padStart(2, '0')}-01`,
|
|
11
|
+
value: 100,
|
|
12
|
+
label: `Item ${i}`
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('enables prev button when data exceeds visible count', () => {
|
|
17
|
+
render(<SmartBarChart data={mockData} view="month" />); // VISIBLE_COUNT = 12
|
|
18
|
+
// mockData has 24 items.
|
|
19
|
+
// Initial window: last 12 items (indices 12-23).
|
|
20
|
+
// Prev button should be enabled because we can go back to indices 0-11.
|
|
21
|
+
|
|
22
|
+
const prevBtn = screen.getByText('<');
|
|
23
|
+
const nextBtn = screen.getByText('>');
|
|
24
|
+
|
|
25
|
+
expect(prevBtn).not.toBeDisabled();
|
|
26
|
+
expect(nextBtn).toBeDisabled(); // Already at the end (newest)
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('updates window offset when prev button is clicked', () => {
|
|
30
|
+
render(<SmartBarChart data={mockData} view="month" />);
|
|
31
|
+
const prevBtn = screen.getByText('<');
|
|
32
|
+
|
|
33
|
+
fireEvent.click(prevBtn);
|
|
34
|
+
// Window moves back by 1.
|
|
35
|
+
|
|
36
|
+
// Next button should now be enabled
|
|
37
|
+
const nextBtn = screen.getByText('>');
|
|
38
|
+
expect(nextBtn).not.toBeDisabled();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -28,8 +28,8 @@ export const Bar: React.FC<BarProps> = ({
|
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
30
|
<motion.rect
|
|
31
|
-
initial={{ height: 0, y: y + height }}
|
|
32
|
-
animate={{ height, y }}
|
|
31
|
+
initial={{ height: 0, y: y + height, x }}
|
|
32
|
+
animate={{ height, y, x }}
|
|
33
33
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
34
34
|
width={width}
|
|
35
35
|
fill={fillColor}
|