@papernote/ui 1.10.9 → 1.10.11
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/dist/components/Badge.d.ts +3 -1
- package/dist/components/Badge.d.ts.map +1 -1
- package/dist/components/Button.d.ts +4 -0
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/DataTable.d.ts +5 -1
- package/dist/components/DataTable.d.ts.map +1 -1
- package/dist/components/Progress.d.ts +5 -1
- package/dist/components/Progress.d.ts.map +1 -1
- package/dist/index.d.ts +17 -3
- package/dist/index.esm.js +135 -24
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +133 -22
- package/dist/index.js.map +1 -1
- package/dist/styles.css +54 -5
- package/package.json +1 -1
- package/src/components/Badge.stories.tsx +56 -0
- package/src/components/Badge.tsx +5 -0
- package/src/components/Button.stories.tsx +98 -0
- package/src/components/Button.tsx +45 -6
- package/src/components/DataTable.stories.tsx +63 -1
- package/src/components/DataTable.tsx +41 -1
- package/src/components/Progress.stories.tsx +116 -0
- package/src/components/Progress.tsx +153 -26
- package/tailwind.config.js +12 -0
package/dist/styles.css
CHANGED
|
@@ -2308,11 +2308,6 @@ input:checked + .slider:before{
|
|
|
2308
2308
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
|
2309
2309
|
}
|
|
2310
2310
|
|
|
2311
|
-
.-rotate-90{
|
|
2312
|
-
--tw-rotate: -90deg;
|
|
2313
|
-
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
|
2314
|
-
}
|
|
2315
|
-
|
|
2316
2311
|
.rotate-0{
|
|
2317
2312
|
--tw-rotate: 0deg;
|
|
2318
2313
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
|
@@ -2399,6 +2394,25 @@ input:checked + .slider:before{
|
|
|
2399
2394
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
2400
2395
|
}
|
|
2401
2396
|
|
|
2397
|
+
@keyframes rowFlash{
|
|
2398
|
+
|
|
2399
|
+
0%{
|
|
2400
|
+
background-color: rgb(187 247 208);
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
25%{
|
|
2404
|
+
background-color: rgb(187 247 208);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
100%{
|
|
2408
|
+
background-color: transparent;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
.animate-row-flash{
|
|
2413
|
+
animation: rowFlash 2s ease-out;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2402
2416
|
@keyframes scaleIn{
|
|
2403
2417
|
|
|
2404
2418
|
0%{
|
|
@@ -2595,6 +2609,28 @@ input:checked + .slider:before{
|
|
|
2595
2609
|
animation: spin 1s linear infinite;
|
|
2596
2610
|
}
|
|
2597
2611
|
|
|
2612
|
+
@keyframes successCheck{
|
|
2613
|
+
|
|
2614
|
+
0%{
|
|
2615
|
+
transform: scale(0);
|
|
2616
|
+
opacity: 0;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
50%{
|
|
2620
|
+
transform: scale(1.2);
|
|
2621
|
+
opacity: 1;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
100%{
|
|
2625
|
+
transform: scale(1);
|
|
2626
|
+
opacity: 1;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
.animate-success-check{
|
|
2631
|
+
animation: successCheck 0.6s ease-out;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2598
2634
|
.cursor-col-resize{
|
|
2599
2635
|
cursor: col-resize;
|
|
2600
2636
|
}
|
|
@@ -3451,6 +3487,11 @@ input:checked + .slider:before{
|
|
|
3451
3487
|
background-color: rgb(87 83 78 / var(--tw-bg-opacity, 1));
|
|
3452
3488
|
}
|
|
3453
3489
|
|
|
3490
|
+
.bg-ink-700{
|
|
3491
|
+
--tw-bg-opacity: 1;
|
|
3492
|
+
background-color: rgb(68 64 60 / var(--tw-bg-opacity, 1));
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3454
3495
|
.bg-ink-800{
|
|
3455
3496
|
--tw-bg-opacity: 1;
|
|
3456
3497
|
background-color: rgb(41 37 36 / var(--tw-bg-opacity, 1));
|
|
@@ -3771,6 +3812,14 @@ input:checked + .slider:before{
|
|
|
3771
3812
|
fill: currentColor;
|
|
3772
3813
|
}
|
|
3773
3814
|
|
|
3815
|
+
.fill-ink-400{
|
|
3816
|
+
fill: #a8a29e;
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
.fill-ink-700{
|
|
3820
|
+
fill: #44403c;
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3774
3823
|
.fill-warning-500{
|
|
3775
3824
|
fill: #f59e0b;
|
|
3776
3825
|
}
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { useState } from 'react';
|
|
2
3
|
import Badge from './Badge';
|
|
3
4
|
import { Check, X, AlertCircle, Info as InfoIcon } from 'lucide-react';
|
|
4
5
|
|
|
@@ -249,3 +250,58 @@ export const CountBadges: Story = {
|
|
|
249
250
|
</div>
|
|
250
251
|
),
|
|
251
252
|
};
|
|
253
|
+
|
|
254
|
+
export const Animated: Story = {
|
|
255
|
+
args: {
|
|
256
|
+
children: 'Animated Badge',
|
|
257
|
+
variant: 'success',
|
|
258
|
+
animate: true,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export const AnimatedBadges: Story = {
|
|
263
|
+
render: () => {
|
|
264
|
+
const [badges, setBadges] = useState<string[]>([]);
|
|
265
|
+
const [counter, setCounter] = useState(0);
|
|
266
|
+
|
|
267
|
+
const addBadge = () => {
|
|
268
|
+
const newBadge = `Badge ${counter + 1}`;
|
|
269
|
+
setBadges([...badges, newBadge]);
|
|
270
|
+
setCounter(counter + 1);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const removeBadge = (index: number) => {
|
|
274
|
+
setBadges(badges.filter((_, i) => i !== index));
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
279
|
+
<button
|
|
280
|
+
onClick={addBadge}
|
|
281
|
+
style={{
|
|
282
|
+
padding: '0.5rem 1rem',
|
|
283
|
+
border: '1px solid #e5e5e5',
|
|
284
|
+
borderRadius: '0.375rem',
|
|
285
|
+
background: 'white',
|
|
286
|
+
cursor: 'pointer',
|
|
287
|
+
width: 'fit-content',
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
Add Badge
|
|
291
|
+
</button>
|
|
292
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', minHeight: '2rem' }}>
|
|
293
|
+
{badges.map((badge, index) => (
|
|
294
|
+
<Badge
|
|
295
|
+
key={badge}
|
|
296
|
+
variant="info"
|
|
297
|
+
animate
|
|
298
|
+
onRemove={() => removeBadge(index)}
|
|
299
|
+
>
|
|
300
|
+
{badge}
|
|
301
|
+
</Badge>
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
},
|
|
307
|
+
};
|
package/src/components/Badge.tsx
CHANGED
|
@@ -16,6 +16,8 @@ export interface BadgeProps {
|
|
|
16
16
|
truncate?: boolean;
|
|
17
17
|
/** Maximum width for the badge (useful with truncate), e.g. '150px' or '10rem' */
|
|
18
18
|
maxWidth?: string;
|
|
19
|
+
/** Apply fade-in animation when badge appears */
|
|
20
|
+
animate?: boolean;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export default function Badge({
|
|
@@ -29,6 +31,7 @@ export default function Badge({
|
|
|
29
31
|
pill = false,
|
|
30
32
|
truncate = false,
|
|
31
33
|
maxWidth,
|
|
34
|
+
animate = false,
|
|
32
35
|
}: BadgeProps) {
|
|
33
36
|
const variantStyles = {
|
|
34
37
|
success: 'bg-success-50 text-success-700 border-success-200',
|
|
@@ -79,6 +82,7 @@ export default function Badge({
|
|
|
79
82
|
inline-block rounded-full
|
|
80
83
|
${dotVariantStyles[variant]}
|
|
81
84
|
${dotSizeStyles[size]}
|
|
85
|
+
${animate ? 'animate-fade-in' : ''}
|
|
82
86
|
${className}
|
|
83
87
|
`}
|
|
84
88
|
aria-label={`${variant} indicator`}
|
|
@@ -95,6 +99,7 @@ export default function Badge({
|
|
|
95
99
|
${variantStyles[variant]}
|
|
96
100
|
${pill ? pillSizeStyles[size] : sizeStyles[size]}
|
|
97
101
|
${truncate ? 'max-w-full overflow-hidden' : ''}
|
|
102
|
+
${animate ? 'animate-fade-in' : ''}
|
|
98
103
|
${className}
|
|
99
104
|
`}
|
|
100
105
|
style={maxWidth ? { maxWidth } : undefined}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { useState } from 'react';
|
|
2
3
|
import Button from './Button';
|
|
3
4
|
import { Save, Trash, Plus, Download } from 'lucide-react';
|
|
4
5
|
|
|
@@ -253,3 +254,100 @@ export const AllSizes: Story = {
|
|
|
253
254
|
</div>
|
|
254
255
|
),
|
|
255
256
|
};
|
|
257
|
+
|
|
258
|
+
export const SuccessAnimation: Story = {
|
|
259
|
+
args: {
|
|
260
|
+
variant: 'primary',
|
|
261
|
+
showSuccess: true,
|
|
262
|
+
children: 'Saved!',
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export const SaveWithSuccess: Story = {
|
|
267
|
+
render: () => {
|
|
268
|
+
const [saving, setSaving] = useState(false);
|
|
269
|
+
const [saved, setSaved] = useState(false);
|
|
270
|
+
|
|
271
|
+
const handleSave = async () => {
|
|
272
|
+
setSaving(true);
|
|
273
|
+
setSaved(false);
|
|
274
|
+
|
|
275
|
+
// Simulate API call
|
|
276
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
277
|
+
|
|
278
|
+
setSaving(false);
|
|
279
|
+
setSaved(true);
|
|
280
|
+
|
|
281
|
+
// Reset after animation completes
|
|
282
|
+
setTimeout(() => setSaved(false), 1500);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
|
|
287
|
+
<Button
|
|
288
|
+
variant="primary"
|
|
289
|
+
icon={<Save />}
|
|
290
|
+
loading={saving}
|
|
291
|
+
showSuccess={saved}
|
|
292
|
+
onClick={handleSave}
|
|
293
|
+
>
|
|
294
|
+
Save Changes
|
|
295
|
+
</Button>
|
|
296
|
+
<p style={{ fontSize: '0.875rem', color: '#64748b' }}>
|
|
297
|
+
Click to simulate save with success animation
|
|
298
|
+
</p>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
export const SuccessAnimationVariants: Story = {
|
|
305
|
+
render: () => {
|
|
306
|
+
const [successStates, setSuccessStates] = useState<Record<string, boolean>>({});
|
|
307
|
+
|
|
308
|
+
const triggerSuccess = (key: string) => {
|
|
309
|
+
setSuccessStates(prev => ({ ...prev, [key]: true }));
|
|
310
|
+
setTimeout(() => {
|
|
311
|
+
setSuccessStates(prev => ({ ...prev, [key]: false }));
|
|
312
|
+
}, 1500);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
317
|
+
<p style={{ fontSize: '0.875rem', color: '#64748b', marginBottom: '0.5rem' }}>
|
|
318
|
+
Click any button to see the success animation
|
|
319
|
+
</p>
|
|
320
|
+
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
|
321
|
+
<Button
|
|
322
|
+
variant="primary"
|
|
323
|
+
showSuccess={successStates['primary']}
|
|
324
|
+
onClick={() => triggerSuccess('primary')}
|
|
325
|
+
>
|
|
326
|
+
Primary
|
|
327
|
+
</Button>
|
|
328
|
+
<Button
|
|
329
|
+
variant="secondary"
|
|
330
|
+
showSuccess={successStates['secondary']}
|
|
331
|
+
onClick={() => triggerSuccess('secondary')}
|
|
332
|
+
>
|
|
333
|
+
Secondary
|
|
334
|
+
</Button>
|
|
335
|
+
<Button
|
|
336
|
+
variant="outline"
|
|
337
|
+
showSuccess={successStates['outline']}
|
|
338
|
+
onClick={() => triggerSuccess('outline')}
|
|
339
|
+
>
|
|
340
|
+
Outline
|
|
341
|
+
</Button>
|
|
342
|
+
<Button
|
|
343
|
+
variant="ghost"
|
|
344
|
+
showSuccess={successStates['ghost']}
|
|
345
|
+
onClick={() => triggerSuccess('ghost')}
|
|
346
|
+
>
|
|
347
|
+
Ghost
|
|
348
|
+
</Button>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
},
|
|
353
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, { forwardRef } from 'react';
|
|
2
|
-
import { Loader2 } from 'lucide-react';
|
|
1
|
+
import React, { forwardRef, useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Loader2, Check } from 'lucide-react';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Button component props
|
|
@@ -23,6 +23,10 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
|
|
|
23
23
|
badge?: number | string;
|
|
24
24
|
/** Badge color variant */
|
|
25
25
|
badgeVariant?: 'primary' | 'success' | 'warning' | 'error';
|
|
26
|
+
/** Show success checkmark animation (briefly shows checkmark, then reverts) */
|
|
27
|
+
showSuccess?: boolean;
|
|
28
|
+
/** Duration in ms for success animation (default: 1500) */
|
|
29
|
+
successDuration?: number;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
/**
|
|
@@ -76,11 +80,40 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
|
|
76
80
|
iconOnly = false,
|
|
77
81
|
badge,
|
|
78
82
|
badgeVariant = 'error',
|
|
83
|
+
showSuccess = false,
|
|
84
|
+
successDuration = 1500,
|
|
79
85
|
children,
|
|
80
86
|
disabled,
|
|
81
87
|
className = '',
|
|
82
88
|
...props
|
|
83
89
|
}, ref) => {
|
|
90
|
+
// Track success animation state
|
|
91
|
+
const [isShowingSuccess, setIsShowingSuccess] = useState(false);
|
|
92
|
+
const successTimeoutRef = useRef<number | null>(null);
|
|
93
|
+
|
|
94
|
+
// Handle showSuccess prop changes
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (showSuccess && !isShowingSuccess) {
|
|
97
|
+
setIsShowingSuccess(true);
|
|
98
|
+
|
|
99
|
+
// Clear any existing timeout
|
|
100
|
+
if (successTimeoutRef.current) {
|
|
101
|
+
window.clearTimeout(successTimeoutRef.current);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Set timeout to revert back
|
|
105
|
+
successTimeoutRef.current = window.setTimeout(() => {
|
|
106
|
+
setIsShowingSuccess(false);
|
|
107
|
+
}, successDuration);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
if (successTimeoutRef.current) {
|
|
112
|
+
window.clearTimeout(successTimeoutRef.current);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}, [showSuccess, successDuration, isShowingSuccess]);
|
|
116
|
+
|
|
84
117
|
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400 disabled:opacity-40 disabled:cursor-not-allowed';
|
|
85
118
|
|
|
86
119
|
const variantStyles = {
|
|
@@ -117,12 +150,15 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
|
|
117
150
|
lg: 'min-w-[20px] h-5 text-xs px-1.5',
|
|
118
151
|
};
|
|
119
152
|
|
|
153
|
+
// Determine what to show inside button
|
|
154
|
+
const showSuccessState = isShowingSuccess && !loading;
|
|
155
|
+
|
|
120
156
|
const buttonElement = (
|
|
121
157
|
<button
|
|
122
158
|
ref={ref}
|
|
123
159
|
className={`
|
|
124
160
|
${baseStyles}
|
|
125
|
-
${variantStyles[variant]}
|
|
161
|
+
${showSuccessState ? 'bg-success-500 border-success-500 text-white' : variantStyles[variant]}
|
|
126
162
|
${sizeStyles[size]}
|
|
127
163
|
${fullWidth && !iconOnly ? 'w-full' : ''}
|
|
128
164
|
${className}
|
|
@@ -134,11 +170,14 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
|
|
134
170
|
{loading && (
|
|
135
171
|
<Loader2 className={`${iconSize[size]} animate-spin`} />
|
|
136
172
|
)}
|
|
137
|
-
{
|
|
173
|
+
{showSuccessState && (
|
|
174
|
+
<Check className={`${iconSize[size]} animate-success-check`} />
|
|
175
|
+
)}
|
|
176
|
+
{!loading && !showSuccessState && icon && iconPosition === 'left' && (
|
|
138
177
|
<span className={iconSize[size]}>{icon}</span>
|
|
139
178
|
)}
|
|
140
|
-
{!iconOnly && children}
|
|
141
|
-
{!loading && icon && iconPosition === 'right' && !iconOnly && (
|
|
179
|
+
{!iconOnly && !showSuccessState && children}
|
|
180
|
+
{!loading && !showSuccessState && icon && iconPosition === 'right' && !iconOnly && (
|
|
142
181
|
<span className={iconSize[size]}>{icon}</span>
|
|
143
182
|
)}
|
|
144
183
|
</button>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
2
|
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
3
|
import DataTable from './DataTable';
|
|
4
4
|
import Badge from './Badge';
|
|
5
|
+
import Button from './Button';
|
|
5
6
|
import { Edit, Trash, Eye } from 'lucide-react';
|
|
6
7
|
|
|
7
8
|
interface User {
|
|
@@ -432,3 +433,64 @@ export const FullWidthWithSecondaryRow: Story = {
|
|
|
432
433
|
],
|
|
433
434
|
},
|
|
434
435
|
};
|
|
436
|
+
|
|
437
|
+
export const RowHighlighting: Story = {
|
|
438
|
+
render: () => {
|
|
439
|
+
const [highlightedRows, setHighlightedRows] = useState<string[]>([]);
|
|
440
|
+
|
|
441
|
+
const simulateSave = (rowId: string) => {
|
|
442
|
+
setHighlightedRows([rowId]);
|
|
443
|
+
// Clear after animation completes
|
|
444
|
+
setTimeout(() => setHighlightedRows([]), 2000);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
return (
|
|
448
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
449
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
450
|
+
<Button variant="secondary" size="sm" onClick={() => simulateSave('1')}>
|
|
451
|
+
Flash Row 1
|
|
452
|
+
</Button>
|
|
453
|
+
<Button variant="secondary" size="sm" onClick={() => simulateSave('2')}>
|
|
454
|
+
Flash Row 2
|
|
455
|
+
</Button>
|
|
456
|
+
<Button variant="secondary" size="sm" onClick={() => simulateSave('3')}>
|
|
457
|
+
Flash Row 3
|
|
458
|
+
</Button>
|
|
459
|
+
<Button
|
|
460
|
+
variant="primary"
|
|
461
|
+
size="sm"
|
|
462
|
+
onClick={() => {
|
|
463
|
+
setHighlightedRows(['1', '3', '5']);
|
|
464
|
+
setTimeout(() => setHighlightedRows([]), 2000);
|
|
465
|
+
}}
|
|
466
|
+
>
|
|
467
|
+
Flash Multiple
|
|
468
|
+
</Button>
|
|
469
|
+
</div>
|
|
470
|
+
<DataTable
|
|
471
|
+
data={sampleUsers}
|
|
472
|
+
columns={[
|
|
473
|
+
{ key: 'name', header: 'Name', sortable: true },
|
|
474
|
+
{ key: 'email', header: 'Email', sortable: true },
|
|
475
|
+
{ key: 'role', header: 'Role' },
|
|
476
|
+
{
|
|
477
|
+
key: 'status',
|
|
478
|
+
header: 'Status',
|
|
479
|
+
render: (user: User) => (
|
|
480
|
+
<Badge variant={user.status === 'active' ? 'success' : user.status === 'inactive' ? 'error' : 'warning'}>
|
|
481
|
+
{user.status}
|
|
482
|
+
</Badge>
|
|
483
|
+
),
|
|
484
|
+
},
|
|
485
|
+
]}
|
|
486
|
+
highlightedRows={highlightedRows}
|
|
487
|
+
highlightDuration={2000}
|
|
488
|
+
/>
|
|
489
|
+
<p style={{ fontSize: '0.875rem', color: '#64748b' }}>
|
|
490
|
+
Click a button to see the row flash green and fade back to normal.
|
|
491
|
+
This is useful for indicating successful saves or updates.
|
|
492
|
+
</p>
|
|
493
|
+
</div>
|
|
494
|
+
);
|
|
495
|
+
},
|
|
496
|
+
};
|
|
@@ -207,6 +207,10 @@ interface DataTableProps<T extends BaseDataItem = BaseDataItem> {
|
|
|
207
207
|
rowHighlight?: (item: T) => string | undefined;
|
|
208
208
|
/** ID of a single row to highlight */
|
|
209
209
|
highlightedRowId?: string | number;
|
|
210
|
+
/** Array of row IDs to temporarily highlight (flash animation) */
|
|
211
|
+
highlightedRows?: (string | number)[];
|
|
212
|
+
/** Duration in ms for temporary row highlight (default: 2000) */
|
|
213
|
+
highlightDuration?: number;
|
|
210
214
|
/** Enable cell borders */
|
|
211
215
|
bordered?: boolean;
|
|
212
216
|
/** Custom border color (Tailwind class like 'border-paper-200') */
|
|
@@ -513,6 +517,8 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
513
517
|
rowClassName,
|
|
514
518
|
rowHighlight,
|
|
515
519
|
highlightedRowId,
|
|
520
|
+
highlightedRows = [],
|
|
521
|
+
highlightDuration = 2000,
|
|
516
522
|
bordered = false,
|
|
517
523
|
borderColor = 'border-paper-200',
|
|
518
524
|
disableHover = false,
|
|
@@ -561,6 +567,10 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
561
567
|
// Row hover state (for coordinating primary + secondary row highlighting)
|
|
562
568
|
const [hoveredRowKey, setHoveredRowKey] = useState<string | null>(null);
|
|
563
569
|
|
|
570
|
+
// Temporary row highlight state (for flash animation)
|
|
571
|
+
const [flashingRows, setFlashingRows] = useState<Set<string>>(new Set());
|
|
572
|
+
const flashTimeoutRef = useRef<number | null>(null);
|
|
573
|
+
|
|
564
574
|
// Context menu state
|
|
565
575
|
const [contextMenuState, setContextMenuState] = useState<{
|
|
566
576
|
isOpen: boolean;
|
|
@@ -584,6 +594,31 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
584
594
|
}
|
|
585
595
|
}, [baseVisibleColumns, columnOrder.length]);
|
|
586
596
|
|
|
597
|
+
// Handle temporary row highlighting (flash animation)
|
|
598
|
+
useEffect(() => {
|
|
599
|
+
if (highlightedRows.length > 0) {
|
|
600
|
+
// Add new highlighted rows to flashing set
|
|
601
|
+
const newFlashingRows = new Set(highlightedRows.map(id => String(id)));
|
|
602
|
+
setFlashingRows(newFlashingRows);
|
|
603
|
+
|
|
604
|
+
// Clear any existing timeout
|
|
605
|
+
if (flashTimeoutRef.current) {
|
|
606
|
+
window.clearTimeout(flashTimeoutRef.current);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Set timeout to clear the flash
|
|
610
|
+
flashTimeoutRef.current = window.setTimeout(() => {
|
|
611
|
+
setFlashingRows(new Set());
|
|
612
|
+
}, highlightDuration);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return () => {
|
|
616
|
+
if (flashTimeoutRef.current) {
|
|
617
|
+
window.clearTimeout(flashTimeoutRef.current);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
}, [highlightedRows, highlightDuration]);
|
|
621
|
+
|
|
587
622
|
// Apply column order
|
|
588
623
|
const visibleColumns = reorderable && columnOrder.length > 0
|
|
589
624
|
? columnOrder
|
|
@@ -618,9 +653,14 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
618
653
|
// Get row background class based on striping and highlighting
|
|
619
654
|
const getRowBackgroundClass = (item: T, index: number): string => {
|
|
620
655
|
const classes: string[] = [];
|
|
656
|
+
const rowKey = getRowKey(item);
|
|
621
657
|
|
|
658
|
+
// Check for temporary flash highlight (takes priority)
|
|
659
|
+
if (flashingRows.has(rowKey)) {
|
|
660
|
+
classes.push('animate-row-flash');
|
|
661
|
+
}
|
|
622
662
|
// Check for highlighted row
|
|
623
|
-
if (highlightedRowId !== undefined &&
|
|
663
|
+
else if (highlightedRowId !== undefined && rowKey === String(highlightedRowId)) {
|
|
624
664
|
classes.push('bg-accent-100');
|
|
625
665
|
}
|
|
626
666
|
// Check for custom row highlight
|
|
@@ -410,3 +410,119 @@ export const MultipleProgress: Story = {
|
|
|
410
410
|
</div>
|
|
411
411
|
),
|
|
412
412
|
};
|
|
413
|
+
|
|
414
|
+
export const WithMilestones: Story = {
|
|
415
|
+
args: {
|
|
416
|
+
value: 65,
|
|
417
|
+
milestones: [25, 50, 75, 100],
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
export const WithMilestoneLabels: Story = {
|
|
422
|
+
args: {
|
|
423
|
+
value: 75,
|
|
424
|
+
milestones: [25, 50, 75, 100],
|
|
425
|
+
showMilestoneLabels: true,
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export const CircularWithMilestones: Story = {
|
|
430
|
+
args: {
|
|
431
|
+
value: 60,
|
|
432
|
+
variant: 'circular',
|
|
433
|
+
milestones: [25, 50, 75, 100],
|
|
434
|
+
showLabel: true,
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
export const CircularWithMilestoneLabels: Story = {
|
|
439
|
+
args: {
|
|
440
|
+
value: 80,
|
|
441
|
+
variant: 'circular',
|
|
442
|
+
milestones: [25, 50, 75, 100],
|
|
443
|
+
showMilestoneLabels: true,
|
|
444
|
+
showLabel: true,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
export const BudgetTracker: Story = {
|
|
449
|
+
render: () => (
|
|
450
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem', width: '100%' }}>
|
|
451
|
+
<div>
|
|
452
|
+
<div style={{ fontSize: '0.875rem', marginBottom: '0.5rem', color: '#64748b' }}>Monthly Budget</div>
|
|
453
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
|
454
|
+
<span style={{ fontWeight: 500 }}>$2,250 / $3,000</span>
|
|
455
|
+
<span style={{ color: '#f59e0b' }}>75% used</span>
|
|
456
|
+
</div>
|
|
457
|
+
<Progress
|
|
458
|
+
value={75}
|
|
459
|
+
color="warning"
|
|
460
|
+
milestones={[25, 50, 75, 100]}
|
|
461
|
+
showMilestoneLabels
|
|
462
|
+
/>
|
|
463
|
+
</div>
|
|
464
|
+
<div>
|
|
465
|
+
<div style={{ fontSize: '0.875rem', marginBottom: '0.5rem', color: '#64748b' }}>Savings Goal</div>
|
|
466
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
|
467
|
+
<span style={{ fontWeight: 500 }}>$4,500 / $10,000</span>
|
|
468
|
+
<span style={{ color: '#3b82f6' }}>45% complete</span>
|
|
469
|
+
</div>
|
|
470
|
+
<Progress
|
|
471
|
+
value={45}
|
|
472
|
+
color="primary"
|
|
473
|
+
milestones={[25, 50, 75, 100]}
|
|
474
|
+
showMilestoneLabels
|
|
475
|
+
/>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
),
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
export const GoalProgress: Story = {
|
|
482
|
+
render: () => (
|
|
483
|
+
<div style={{ display: 'flex', gap: '3rem', alignItems: 'center' }}>
|
|
484
|
+
<div style={{ textAlign: 'center' }}>
|
|
485
|
+
<Progress
|
|
486
|
+
value={85}
|
|
487
|
+
variant="circular"
|
|
488
|
+
size="lg"
|
|
489
|
+
color="success"
|
|
490
|
+
milestones={[25, 50, 75, 100]}
|
|
491
|
+
showLabel
|
|
492
|
+
/>
|
|
493
|
+
<div style={{ fontSize: '0.875rem', color: '#64748b', marginTop: '0.5rem' }}>Sales Target</div>
|
|
494
|
+
</div>
|
|
495
|
+
<div style={{ textAlign: 'center' }}>
|
|
496
|
+
<Progress
|
|
497
|
+
value={60}
|
|
498
|
+
variant="circular"
|
|
499
|
+
size="lg"
|
|
500
|
+
color="primary"
|
|
501
|
+
milestones={[25, 50, 75, 100]}
|
|
502
|
+
showLabel
|
|
503
|
+
/>
|
|
504
|
+
<div style={{ fontSize: '0.875rem', color: '#64748b', marginTop: '0.5rem' }}>Project Completion</div>
|
|
505
|
+
</div>
|
|
506
|
+
<div style={{ textAlign: 'center' }}>
|
|
507
|
+
<Progress
|
|
508
|
+
value={30}
|
|
509
|
+
variant="circular"
|
|
510
|
+
size="lg"
|
|
511
|
+
color="warning"
|
|
512
|
+
milestones={[25, 50, 75, 100]}
|
|
513
|
+
showLabel
|
|
514
|
+
/>
|
|
515
|
+
<div style={{ fontSize: '0.875rem', color: '#64748b', marginTop: '0.5rem' }}>Sprint Progress</div>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
),
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
export const CustomMilestones: Story = {
|
|
522
|
+
args: {
|
|
523
|
+
value: 70,
|
|
524
|
+
milestones: [10, 30, 50, 80, 100],
|
|
525
|
+
showMilestoneLabels: true,
|
|
526
|
+
color: 'success',
|
|
527
|
+
},
|
|
528
|
+
};
|