@papernote/ui 1.10.10 → 1.10.12

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/styles.css CHANGED
@@ -2394,6 +2394,25 @@ input:checked + .slider:before{
2394
2394
  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
2395
2395
  }
2396
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
+
2397
2416
  @keyframes scaleIn{
2398
2417
 
2399
2418
  0%{
@@ -2590,6 +2609,28 @@ input:checked + .slider:before{
2590
2609
  animation: spin 1s linear infinite;
2591
2610
  }
2592
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
+
2593
2634
  .cursor-col-resize{
2594
2635
  cursor: col-resize;
2595
2636
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papernote/ui",
3
- "version": "1.10.10",
3
+ "version": "1.10.12",
4
4
  "type": "module",
5
5
  "description": "A modern React component library with a paper notebook aesthetic - minimal, professional, and expressive",
6
6
  "main": "dist/index.js",
@@ -54,6 +54,7 @@
54
54
  "@testing-library/jest-dom": "^6.9.1",
55
55
  "@testing-library/react": "^16.3.0",
56
56
  "@testing-library/user-event": "^14.6.1",
57
+ "@types/canvas-confetti": "^1.9.0",
57
58
  "@types/jest": "^30.0.0",
58
59
  "@types/react": "^19.2.2",
59
60
  "@types/react-dom": "^19.2.2",
@@ -107,6 +108,7 @@
107
108
  "url": "https://github.com/kwhittenberger/papernote-ui/issues"
108
109
  },
109
110
  "dependencies": {
111
+ "canvas-confetti": "^1.9.4",
110
112
  "react-spreadsheet": "^0.10.1",
111
113
  "xlsx": "^0.18.5"
112
114
  }
@@ -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
+ };
@@ -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
- {!loading && icon && iconPosition === 'left' && (
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>
@@ -0,0 +1,175 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useState } from 'react';
3
+ import { Celebration, useCelebration } from './Celebration';
4
+ import { Button } from './Button';
5
+ import { Stack } from './Stack';
6
+ import { Card, CardHeader, CardTitle, CardContent } from './Card';
7
+
8
+ const meta: Meta<typeof Celebration> = {
9
+ title: 'Feedback/Celebration',
10
+ component: Celebration,
11
+ parameters: {
12
+ layout: 'centered',
13
+ },
14
+ tags: ['autodocs'],
15
+ };
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof Celebration>;
19
+
20
+ // Interactive demo with buttons
21
+ function CelebrationDemo() {
22
+ const [trigger, setTrigger] = useState(false);
23
+ const [celebrationType, setCelebrationType] = useState<'confetti' | 'fireworks' | 'stars'>('confetti');
24
+
25
+ return (
26
+ <Card style={{ width: '400px' }}>
27
+ <CardHeader>
28
+ <CardTitle>Celebration Demo</CardTitle>
29
+ </CardHeader>
30
+ <CardContent>
31
+ <Stack gap="md">
32
+ <Stack direction="horizontal" gap="sm">
33
+ <Button
34
+ variant={celebrationType === 'confetti' ? 'primary' : 'secondary'}
35
+ onClick={() => setCelebrationType('confetti')}
36
+ >
37
+ Confetti
38
+ </Button>
39
+ <Button
40
+ variant={celebrationType === 'fireworks' ? 'primary' : 'secondary'}
41
+ onClick={() => setCelebrationType('fireworks')}
42
+ >
43
+ Fireworks
44
+ </Button>
45
+ <Button
46
+ variant={celebrationType === 'stars' ? 'primary' : 'secondary'}
47
+ onClick={() => setCelebrationType('stars')}
48
+ >
49
+ Stars
50
+ </Button>
51
+ </Stack>
52
+ <Button
53
+ variant="primary"
54
+ size="lg"
55
+ onClick={() => setTrigger(true)}
56
+ >
57
+ 🎉 Celebrate!
58
+ </Button>
59
+ </Stack>
60
+ </CardContent>
61
+ <Celebration
62
+ trigger={trigger}
63
+ type={celebrationType}
64
+ onComplete={() => setTrigger(false)}
65
+ />
66
+ </Card>
67
+ );
68
+ }
69
+
70
+ export const Interactive: Story = {
71
+ render: () => <CelebrationDemo />,
72
+ };
73
+
74
+ // Hook demo
75
+ function HookDemo() {
76
+ const { celebrate } = useCelebration();
77
+
78
+ return (
79
+ <Card style={{ width: '400px' }}>
80
+ <CardHeader>
81
+ <CardTitle>Using useCelebration Hook</CardTitle>
82
+ </CardHeader>
83
+ <CardContent>
84
+ <Stack gap="sm">
85
+ <Button onClick={() => celebrate()}>
86
+ Default Confetti
87
+ </Button>
88
+ <Button onClick={() => celebrate({ type: 'fireworks', duration: 3000 })}>
89
+ Fireworks (3s)
90
+ </Button>
91
+ <Button onClick={() => celebrate({ type: 'stars', colors: ['#ffd700', '#ffed4a', '#fff'] })}>
92
+ Golden Stars
93
+ </Button>
94
+ <Button onClick={() => celebrate({ colors: ['#22c55e'], particleCount: 200 })}>
95
+ Green Confetti
96
+ </Button>
97
+ </Stack>
98
+ </CardContent>
99
+ </Card>
100
+ );
101
+ }
102
+
103
+ export const WithHook: Story = {
104
+ render: () => <HookDemo />,
105
+ };
106
+
107
+ // Goal completion scenario
108
+ function GoalCompletionDemo() {
109
+ const [goalCompleted, setGoalCompleted] = useState(false);
110
+
111
+ return (
112
+ <Card style={{ width: '400px' }}>
113
+ <CardHeader>
114
+ <CardTitle>Goal: Save $1,000</CardTitle>
115
+ </CardHeader>
116
+ <CardContent>
117
+ <Stack gap="md">
118
+ <div style={{
119
+ width: '100%',
120
+ height: '8px',
121
+ backgroundColor: '#e5e7eb',
122
+ borderRadius: '4px',
123
+ overflow: 'hidden'
124
+ }}>
125
+ <div style={{
126
+ width: goalCompleted ? '100%' : '85%',
127
+ height: '100%',
128
+ backgroundColor: goalCompleted ? '#22c55e' : '#3b82f6',
129
+ transition: 'width 0.5s ease'
130
+ }} />
131
+ </div>
132
+ <Button
133
+ variant="primary"
134
+ onClick={() => setGoalCompleted(true)}
135
+ disabled={goalCompleted}
136
+ >
137
+ {goalCompleted ? '🎉 Goal Reached!' : 'Complete Goal'}
138
+ </Button>
139
+ </Stack>
140
+ </CardContent>
141
+ <Celebration
142
+ trigger={goalCompleted}
143
+ type="fireworks"
144
+ duration={3000}
145
+ colors={['#22c55e', '#10b981', '#34d399']}
146
+ />
147
+ </Card>
148
+ );
149
+ }
150
+
151
+ export const GoalCompletion: Story = {
152
+ render: () => <GoalCompletionDemo />,
153
+ };
154
+
155
+ export const Confetti: Story = {
156
+ args: {
157
+ trigger: true,
158
+ type: 'confetti',
159
+ },
160
+ };
161
+
162
+ export const Fireworks: Story = {
163
+ args: {
164
+ trigger: true,
165
+ type: 'fireworks',
166
+ duration: 3000,
167
+ },
168
+ };
169
+
170
+ export const Stars: Story = {
171
+ args: {
172
+ trigger: true,
173
+ type: 'stars',
174
+ },
175
+ };