@papernote/ui 1.1.0 → 1.3.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/LICENSE +21 -21
- package/README.md +455 -455
- package/dist/components/Box.d.ts +2 -1
- package/dist/components/Box.d.ts.map +1 -1
- package/dist/components/Button.d.ts +10 -1
- package/dist/components/Button.d.ts.map +1 -1
- package/dist/components/Card.d.ts +11 -2
- package/dist/components/Card.d.ts.map +1 -1
- package/dist/components/CurrencyInput.d.ts +52 -0
- package/dist/components/CurrencyInput.d.ts.map +1 -0
- package/dist/components/DataTable.d.ts +19 -3
- package/dist/components/DataTable.d.ts.map +1 -1
- package/dist/components/EmptyState.d.ts +3 -1
- package/dist/components/EmptyState.d.ts.map +1 -1
- package/dist/components/Grid.d.ts +4 -2
- package/dist/components/Grid.d.ts.map +1 -1
- package/dist/components/Input.d.ts +2 -0
- package/dist/components/Input.d.ts.map +1 -1
- package/dist/components/Modal.d.ts.map +1 -1
- package/dist/components/MultiSelect.d.ts +13 -1
- package/dist/components/MultiSelect.d.ts.map +1 -1
- package/dist/components/Page.d.ts +2 -0
- package/dist/components/Page.d.ts.map +1 -1
- package/dist/components/PageLayout.d.ts +5 -1
- package/dist/components/PageLayout.d.ts.map +1 -1
- package/dist/components/Stack.d.ts +25 -5
- package/dist/components/Stack.d.ts.map +1 -1
- package/dist/components/Text.d.ts +20 -4
- package/dist/components/Text.d.ts.map +1 -1
- package/dist/components/Textarea.d.ts +2 -0
- package/dist/components/Textarea.d.ts.map +1 -1
- package/dist/components/index.d.ts +5 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +311 -49
- package/dist/index.esm.js +557 -224
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +555 -219
- package/dist/index.js.map +1 -1
- package/dist/styles.css +2838 -2679
- package/dist/utils/excelExport.d.ts +143 -0
- package/dist/utils/excelExport.d.ts.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/AdminModal.css +49 -49
- package/src/components/Box.stories.tsx +377 -0
- package/src/components/Box.tsx +8 -4
- package/src/components/Button.tsx +23 -10
- package/src/components/Card.tsx +20 -5
- package/src/components/CurrencyInput.stories.tsx +290 -0
- package/src/components/CurrencyInput.tsx +193 -0
- package/src/components/DataTable.stories.tsx +36 -25
- package/src/components/DataTable.tsx +170 -16
- package/src/components/EmptyState.stories.tsx +124 -72
- package/src/components/EmptyState.tsx +10 -0
- package/src/components/Grid.stories.tsx +348 -0
- package/src/components/Grid.tsx +12 -5
- package/src/components/Input.tsx +12 -2
- package/src/components/Modal.stories.tsx +64 -0
- package/src/components/Modal.tsx +15 -2
- package/src/components/MultiSelect.tsx +41 -10
- package/src/components/Page.stories.tsx +76 -0
- package/src/components/Page.tsx +35 -3
- package/src/components/PageLayout.stories.tsx +75 -0
- package/src/components/PageLayout.tsx +28 -9
- package/src/components/RoleManager.css +10 -10
- package/src/components/Spreadsheet.css +216 -216
- package/src/components/Spreadsheet.stories.tsx +362 -362
- package/src/components/Spreadsheet.tsx +351 -351
- package/src/components/SpreadsheetSimple.stories.tsx +27 -27
- package/src/components/Stack.stories.tsx +24 -1
- package/src/components/Stack.tsx +40 -10
- package/src/components/Tabs.tsx +152 -152
- package/src/components/Text.stories.tsx +273 -0
- package/src/components/Text.tsx +33 -8
- package/src/components/Textarea.tsx +32 -21
- package/src/components/index.ts +6 -4
- package/src/styles/index.css +41 -4
- package/src/utils/excelExport.stories.tsx +535 -0
- package/src/utils/excelExport.ts +225 -0
- package/src/utils/index.ts +3 -0
- package/tailwind.config.js +253 -253
- package/dist/components/Button.stories.d.ts +0 -51
- package/dist/components/Button.stories.d.ts.map +0 -1
- package/dist/components/ChartVisualizationUI.d.ts +0 -21
- package/dist/components/ChartVisualizationUI.d.ts.map +0 -1
- package/dist/components/ChatUI.d.ts +0 -23
- package/dist/components/ChatUI.d.ts.map +0 -1
- package/dist/components/CommissionDashboardUI.d.ts +0 -25
- package/dist/components/CommissionDashboardUI.d.ts.map +0 -1
- package/dist/components/DataTable.stories.d.ts +0 -23
- package/dist/components/DataTable.stories.d.ts.map +0 -1
- package/dist/components/FormField.d.ts +0 -35
- package/dist/components/FormField.d.ts.map +0 -1
- package/dist/components/Input.stories.d.ts +0 -366
- package/dist/components/Input.stories.d.ts.map +0 -1
- package/dist/components/InsightsPanelUI.d.ts +0 -21
- package/dist/components/InsightsPanelUI.d.ts.map +0 -1
- package/dist/components/PaymentHistoryTimeline.d.ts +0 -34
- package/dist/components/PaymentHistoryTimeline.d.ts.map +0 -1
- package/dist/components/RelationshipManagerUI.d.ts +0 -60
- package/dist/components/RelationshipManagerUI.d.ts.map +0 -1
- package/dist/components/RoleManager.d.ts +0 -19
- package/dist/components/RoleManager.d.ts.map +0 -1
- package/dist/components/SplitCommissionBadge.d.ts +0 -18
- package/dist/components/SplitCommissionBadge.d.ts.map +0 -1
- package/dist/components/Spreadsheet.css +0 -216
- package/dist/components/Table.d.ts +0 -26
- package/dist/components/Table.d.ts.map +0 -1
- package/dist/components/__tests__/Button.test.d.ts +0 -2
- package/dist/components/__tests__/Button.test.d.ts.map +0 -1
- package/dist/components/__tests__/Input.test.d.ts +0 -2
- package/dist/components/__tests__/Input.test.d.ts.map +0 -1
- package/src/components/Table.tsx +0 -239
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { forwardRef } from 'react';
|
|
2
2
|
import { Loader2 } from 'lucide-react';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -31,6 +31,8 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
|
|
|
31
31
|
* A versatile button component that supports multiple visual styles, sizes, icons,
|
|
32
32
|
* loading states, and notification badges.
|
|
33
33
|
*
|
|
34
|
+
* Supports ref forwarding for DOM access.
|
|
35
|
+
*
|
|
34
36
|
* @example Basic usage
|
|
35
37
|
* ```tsx
|
|
36
38
|
* <Button variant="primary">Click me</Button>
|
|
@@ -38,9 +40,9 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
|
|
|
38
40
|
*
|
|
39
41
|
* @example With icon and loading
|
|
40
42
|
* ```tsx
|
|
41
|
-
* <Button
|
|
42
|
-
* variant="secondary"
|
|
43
|
-
* icon={<Save />}
|
|
43
|
+
* <Button
|
|
44
|
+
* variant="secondary"
|
|
45
|
+
* icon={<Save />}
|
|
44
46
|
* loading={isSaving}
|
|
45
47
|
* >
|
|
46
48
|
* Save Changes
|
|
@@ -49,16 +51,22 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
|
|
|
49
51
|
*
|
|
50
52
|
* @example Icon-only with badge
|
|
51
53
|
* ```tsx
|
|
52
|
-
* <Button
|
|
53
|
-
* iconOnly
|
|
54
|
-
* badge={5}
|
|
54
|
+
* <Button
|
|
55
|
+
* iconOnly
|
|
56
|
+
* badge={5}
|
|
55
57
|
* badgeVariant="error"
|
|
56
58
|
* >
|
|
57
59
|
* <Bell />
|
|
58
60
|
* </Button>
|
|
59
61
|
* ```
|
|
62
|
+
*
|
|
63
|
+
* @example With ref
|
|
64
|
+
* ```tsx
|
|
65
|
+
* const buttonRef = useRef<HTMLButtonElement>(null);
|
|
66
|
+
* <Button ref={buttonRef}>Focusable</Button>
|
|
67
|
+
* ```
|
|
60
68
|
*/
|
|
61
|
-
|
|
69
|
+
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
|
62
70
|
variant = 'primary',
|
|
63
71
|
size = 'md',
|
|
64
72
|
loading = false,
|
|
@@ -72,7 +80,7 @@ export default function Button({
|
|
|
72
80
|
disabled,
|
|
73
81
|
className = '',
|
|
74
82
|
...props
|
|
75
|
-
}
|
|
83
|
+
}, ref) => {
|
|
76
84
|
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';
|
|
77
85
|
|
|
78
86
|
const variantStyles = {
|
|
@@ -111,6 +119,7 @@ export default function Button({
|
|
|
111
119
|
|
|
112
120
|
const buttonElement = (
|
|
113
121
|
<button
|
|
122
|
+
ref={ref}
|
|
114
123
|
className={`
|
|
115
124
|
${baseStyles}
|
|
116
125
|
${variantStyles[variant]}
|
|
@@ -163,4 +172,8 @@ export default function Button({
|
|
|
163
172
|
</span>
|
|
164
173
|
</div>
|
|
165
174
|
);
|
|
166
|
-
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
Button.displayName = 'Button';
|
|
178
|
+
|
|
179
|
+
export default Button;
|
package/src/components/Card.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { forwardRef } from 'react';
|
|
2
2
|
import { Skeleton } from './Loading';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Card component props
|
|
6
6
|
*/
|
|
7
|
-
export interface CardProps {
|
|
7
|
+
export interface CardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onClick'> {
|
|
8
8
|
/** Card content */
|
|
9
9
|
children: React.ReactNode;
|
|
10
10
|
/** Visual style variant affecting padding and shadow */
|
|
@@ -24,6 +24,8 @@ export interface CardProps {
|
|
|
24
24
|
/**
|
|
25
25
|
* Card - Container component with paper aesthetic and subtle shadow
|
|
26
26
|
*
|
|
27
|
+
* Supports ref forwarding for DOM access.
|
|
28
|
+
*
|
|
27
29
|
* A content container with paper texture, border, and shadow effects. Supports
|
|
28
30
|
* different sizes, variants (padding/shadow levels), and loading states.
|
|
29
31
|
*
|
|
@@ -53,8 +55,14 @@ export interface CardProps {
|
|
|
53
55
|
* <p>Content</p>
|
|
54
56
|
* </Card>
|
|
55
57
|
* ```
|
|
58
|
+
*
|
|
59
|
+
* @example With ref
|
|
60
|
+
* ```tsx
|
|
61
|
+
* const cardRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
* <Card ref={cardRef}>Content</Card>
|
|
63
|
+
* ```
|
|
56
64
|
*/
|
|
57
|
-
|
|
65
|
+
const Card = forwardRef<HTMLDivElement, CardProps>(({
|
|
58
66
|
children,
|
|
59
67
|
variant = 'default',
|
|
60
68
|
width = 'auto',
|
|
@@ -62,7 +70,8 @@ export default function Card({
|
|
|
62
70
|
onClick,
|
|
63
71
|
hoverable = false,
|
|
64
72
|
loading = false,
|
|
65
|
-
|
|
73
|
+
...htmlProps
|
|
74
|
+
}, ref) => {
|
|
66
75
|
const baseStyles = 'bg-white bg-subtle-grain border-2 border-paper-300 transition-shadow duration-200';
|
|
67
76
|
|
|
68
77
|
const variantStyles = {
|
|
@@ -84,6 +93,8 @@ export default function Card({
|
|
|
84
93
|
|
|
85
94
|
return (
|
|
86
95
|
<div
|
|
96
|
+
ref={ref}
|
|
97
|
+
{...htmlProps}
|
|
87
98
|
className={`${baseStyles} ${variantStyles[variant]} ${widthStyles[width]} ${interactiveStyles} ${className}`}
|
|
88
99
|
onClick={!loading ? onClick : undefined}
|
|
89
100
|
role={onClick ? 'button' : undefined}
|
|
@@ -101,7 +112,11 @@ export default function Card({
|
|
|
101
112
|
)}
|
|
102
113
|
</div>
|
|
103
114
|
);
|
|
104
|
-
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
Card.displayName = 'Card';
|
|
118
|
+
|
|
119
|
+
export default Card;
|
|
105
120
|
|
|
106
121
|
/**
|
|
107
122
|
* CardHeader component props
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import CurrencyInput from './CurrencyInput';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Forms/CurrencyInput',
|
|
7
|
+
component: CurrencyInput,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'centered',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component: `
|
|
13
|
+
Specialized input component for monetary values with automatic currency formatting.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
- **Auto-formatting**: Formats currency with proper symbols and thousands separators
|
|
17
|
+
- **Smart parsing**: Handles user input gracefully, removing invalid characters
|
|
18
|
+
- **Multiple currencies**: Supports any currency code (USD, EUR, GBP, etc.)
|
|
19
|
+
- **Localization**: Respects locale for number formatting
|
|
20
|
+
- **Validation**: Built-in min/max value constraints
|
|
21
|
+
- **Negative values**: Optional support for negative amounts
|
|
22
|
+
- **Precision control**: Configurable decimal places
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
\`\`\`tsx
|
|
27
|
+
import { CurrencyInput } from 'notebook-ui';
|
|
28
|
+
|
|
29
|
+
const [price, setPrice] = useState<number | null>(1234.56);
|
|
30
|
+
|
|
31
|
+
<CurrencyInput
|
|
32
|
+
label="Price"
|
|
33
|
+
value={price}
|
|
34
|
+
onChange={setPrice}
|
|
35
|
+
currency="USD"
|
|
36
|
+
precision={2}
|
|
37
|
+
/>
|
|
38
|
+
\`\`\`
|
|
39
|
+
`,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
tags: ['autodocs'],
|
|
44
|
+
argTypes: {
|
|
45
|
+
value: {
|
|
46
|
+
control: 'number',
|
|
47
|
+
description: 'Numeric value (unformatted)',
|
|
48
|
+
table: {
|
|
49
|
+
type: { summary: 'number | string' },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
onChange: {
|
|
53
|
+
description: 'Callback when value changes (receives numeric value)',
|
|
54
|
+
table: {
|
|
55
|
+
type: { summary: '(value: number | null) => void' },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
currency: {
|
|
59
|
+
control: 'text',
|
|
60
|
+
description: 'Currency code (ISO 4217)',
|
|
61
|
+
table: {
|
|
62
|
+
type: { summary: 'string' },
|
|
63
|
+
defaultValue: { summary: 'USD' },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
locale: {
|
|
67
|
+
control: 'text',
|
|
68
|
+
description: 'Locale for formatting',
|
|
69
|
+
table: {
|
|
70
|
+
type: { summary: 'string' },
|
|
71
|
+
defaultValue: { summary: 'en-US' },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
precision: {
|
|
75
|
+
control: 'number',
|
|
76
|
+
description: 'Number of decimal places',
|
|
77
|
+
table: {
|
|
78
|
+
type: { summary: 'number' },
|
|
79
|
+
defaultValue: { summary: '2' },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
allowNegative: {
|
|
83
|
+
control: 'boolean',
|
|
84
|
+
description: 'Allow negative values',
|
|
85
|
+
table: {
|
|
86
|
+
type: { summary: 'boolean' },
|
|
87
|
+
defaultValue: { summary: 'false' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
min: {
|
|
91
|
+
control: 'number',
|
|
92
|
+
description: 'Minimum allowed value',
|
|
93
|
+
table: {
|
|
94
|
+
type: { summary: 'number' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
max: {
|
|
98
|
+
control: 'number',
|
|
99
|
+
description: 'Maximum allowed value',
|
|
100
|
+
table: {
|
|
101
|
+
type: { summary: 'number' },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
} satisfies Meta<typeof CurrencyInput>;
|
|
106
|
+
|
|
107
|
+
export default meta;
|
|
108
|
+
type Story = StoryObj<typeof meta>;
|
|
109
|
+
|
|
110
|
+
export const Default: Story = {
|
|
111
|
+
render: () => {
|
|
112
|
+
const [value, setValue] = useState<number | null>(1234.56);
|
|
113
|
+
return (
|
|
114
|
+
<div style={{ width: '300px' }}>
|
|
115
|
+
<CurrencyInput
|
|
116
|
+
label="Price"
|
|
117
|
+
value={value}
|
|
118
|
+
onChange={setValue}
|
|
119
|
+
helperText="Enter the product price"
|
|
120
|
+
/>
|
|
121
|
+
<div style={{ marginTop: '1rem', fontSize: '0.875rem', color: '#64748b' }}>
|
|
122
|
+
Current value: {value !== null ? `$${value.toFixed(2)}` : 'null'}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const WithValidation: Story = {
|
|
130
|
+
render: () => {
|
|
131
|
+
const [value, setValue] = useState<number | null>(150);
|
|
132
|
+
const max = 100;
|
|
133
|
+
const hasError = value !== null && value > max;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div style={{ width: '300px' }}>
|
|
137
|
+
<CurrencyInput
|
|
138
|
+
label="Budget"
|
|
139
|
+
value={value}
|
|
140
|
+
onChange={setValue}
|
|
141
|
+
max={max}
|
|
142
|
+
validationState={hasError ? 'error' : value !== null && value > 0 ? 'success' : null}
|
|
143
|
+
validationMessage={hasError ? `Exceeds maximum budget of $${max}` : value !== null && value > 0 ? 'Within budget' : ''}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const DifferentCurrencies: Story = {
|
|
151
|
+
render: () => {
|
|
152
|
+
const [usd, setUsd] = useState<number | null>(1234.56);
|
|
153
|
+
const [eur, setEur] = useState<number | null>(1234.56);
|
|
154
|
+
const [gbp, setGbp] = useState<number | null>(1234.56);
|
|
155
|
+
const [jpy, setJpy] = useState<number | null>(123456);
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div style={{ width: '300px', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
159
|
+
<CurrencyInput
|
|
160
|
+
label="US Dollars"
|
|
161
|
+
value={usd}
|
|
162
|
+
onChange={setUsd}
|
|
163
|
+
currency="USD"
|
|
164
|
+
locale="en-US"
|
|
165
|
+
/>
|
|
166
|
+
<CurrencyInput
|
|
167
|
+
label="Euros"
|
|
168
|
+
value={eur}
|
|
169
|
+
onChange={setEur}
|
|
170
|
+
currency="EUR"
|
|
171
|
+
locale="de-DE"
|
|
172
|
+
/>
|
|
173
|
+
<CurrencyInput
|
|
174
|
+
label="British Pounds"
|
|
175
|
+
value={gbp}
|
|
176
|
+
onChange={setGbp}
|
|
177
|
+
currency="GBP"
|
|
178
|
+
locale="en-GB"
|
|
179
|
+
/>
|
|
180
|
+
<CurrencyInput
|
|
181
|
+
label="Japanese Yen"
|
|
182
|
+
value={jpy}
|
|
183
|
+
onChange={setJpy}
|
|
184
|
+
currency="JPY"
|
|
185
|
+
locale="ja-JP"
|
|
186
|
+
precision={0}
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const WithNegativeValues: Story = {
|
|
194
|
+
render: () => {
|
|
195
|
+
const [value, setValue] = useState<number | null>(-500.75);
|
|
196
|
+
return (
|
|
197
|
+
<div style={{ width: '300px' }}>
|
|
198
|
+
<CurrencyInput
|
|
199
|
+
label="Profit/Loss"
|
|
200
|
+
value={value}
|
|
201
|
+
onChange={setValue}
|
|
202
|
+
allowNegative
|
|
203
|
+
helperText="Negative values allowed for losses"
|
|
204
|
+
/>
|
|
205
|
+
<div style={{ marginTop: '1rem', fontSize: '0.875rem', color: '#64748b' }}>
|
|
206
|
+
Current value: {value !== null ? `$${value.toFixed(2)}` : 'null'}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const WithMinMax: Story = {
|
|
214
|
+
render: () => {
|
|
215
|
+
const [value, setValue] = useState<number | null>(50);
|
|
216
|
+
const min = 10;
|
|
217
|
+
const max = 100;
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div style={{ width: '300px' }}>
|
|
221
|
+
<CurrencyInput
|
|
222
|
+
label="Amount"
|
|
223
|
+
value={value}
|
|
224
|
+
onChange={setValue}
|
|
225
|
+
min={min}
|
|
226
|
+
max={max}
|
|
227
|
+
helperText={`Min: $${min}, Max: $${max}`}
|
|
228
|
+
/>
|
|
229
|
+
<div style={{ marginTop: '1rem', fontSize: '0.875rem', color: '#64748b' }}>
|
|
230
|
+
Current value: {value !== null ? `$${value.toFixed(2)}` : 'null'}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const Required: Story = {
|
|
238
|
+
render: () => {
|
|
239
|
+
const [value, setValue] = useState<number | null>(null);
|
|
240
|
+
const hasError = value === null;
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div style={{ width: '300px' }}>
|
|
244
|
+
<CurrencyInput
|
|
245
|
+
label="Donation Amount"
|
|
246
|
+
value={value}
|
|
247
|
+
onChange={setValue}
|
|
248
|
+
required
|
|
249
|
+
validationState={hasError ? 'error' : 'success'}
|
|
250
|
+
validationMessage={hasError ? 'Amount is required' : 'Thank you!'}
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export const Clearable: Story = {
|
|
258
|
+
render: () => {
|
|
259
|
+
const [value, setValue] = useState<number | null>(1234.56);
|
|
260
|
+
return (
|
|
261
|
+
<div style={{ width: '300px' }}>
|
|
262
|
+
<CurrencyInput
|
|
263
|
+
label="Price"
|
|
264
|
+
value={value}
|
|
265
|
+
onChange={setValue}
|
|
266
|
+
clearable
|
|
267
|
+
onClear={() => setValue(null)}
|
|
268
|
+
helperText="Click X to clear the value"
|
|
269
|
+
/>
|
|
270
|
+
<div style={{ marginTop: '1rem', fontSize: '0.875rem', color: '#64748b' }}>
|
|
271
|
+
Current value: {value !== null ? `$${value.toFixed(2)}` : 'null'}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const Disabled: Story = {
|
|
279
|
+
render: () => (
|
|
280
|
+
<div style={{ width: '300px' }}>
|
|
281
|
+
<CurrencyInput
|
|
282
|
+
label="Price"
|
|
283
|
+
value={1234.56}
|
|
284
|
+
onChange={() => {}}
|
|
285
|
+
disabled
|
|
286
|
+
helperText="This field is disabled"
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
),
|
|
290
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import React, { forwardRef, useState, useEffect } from 'react';
|
|
2
|
+
import Input, { InputProps } from './Input';
|
|
3
|
+
|
|
4
|
+
export interface CurrencyInputProps extends Omit<InputProps, 'type' | 'value' | 'onChange' | 'prefix'> {
|
|
5
|
+
/** Numeric value (not formatted) */
|
|
6
|
+
value?: number | string;
|
|
7
|
+
/** Callback when value changes (receives numeric value) */
|
|
8
|
+
onChange?: (value: number | null) => void;
|
|
9
|
+
/** Currency code (default: 'USD') */
|
|
10
|
+
currency?: string;
|
|
11
|
+
/** Locale for formatting (default: 'en-US') */
|
|
12
|
+
locale?: string;
|
|
13
|
+
/** Number of decimal places (default: 2) */
|
|
14
|
+
precision?: number;
|
|
15
|
+
/** Allow negative values (default: false) */
|
|
16
|
+
allowNegative?: boolean;
|
|
17
|
+
/** Minimum allowed value */
|
|
18
|
+
min?: number;
|
|
19
|
+
/** Maximum allowed value */
|
|
20
|
+
max?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* CurrencyInput - Specialized input for monetary values
|
|
25
|
+
*
|
|
26
|
+
* Automatically formats currency values with proper symbols and thousands separators.
|
|
27
|
+
* Handles parsing and validation of numeric currency input.
|
|
28
|
+
*
|
|
29
|
+
* @example Basic usage
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <CurrencyInput
|
|
32
|
+
* label="Price"
|
|
33
|
+
* value={price}
|
|
34
|
+
* onChange={setPrice}
|
|
35
|
+
* currency="USD"
|
|
36
|
+
* />
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @example With validation
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <CurrencyInput
|
|
42
|
+
* label="Budget"
|
|
43
|
+
* value={budget}
|
|
44
|
+
* onChange={setBudget}
|
|
45
|
+
* min={0}
|
|
46
|
+
* max={10000}
|
|
47
|
+
* validationState={budget > 10000 ? 'error' : null}
|
|
48
|
+
* validationMessage={budget > 10000 ? 'Exceeds maximum budget' : ''}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
|
|
53
|
+
(
|
|
54
|
+
{
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
currency = 'USD',
|
|
58
|
+
locale = 'en-US',
|
|
59
|
+
precision = 2,
|
|
60
|
+
allowNegative = false,
|
|
61
|
+
min,
|
|
62
|
+
max,
|
|
63
|
+
onBlur,
|
|
64
|
+
onFocus,
|
|
65
|
+
...props
|
|
66
|
+
},
|
|
67
|
+
ref
|
|
68
|
+
) => {
|
|
69
|
+
const [displayValue, setDisplayValue] = useState('');
|
|
70
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
71
|
+
|
|
72
|
+
// Get currency symbol
|
|
73
|
+
const getCurrencySymbol = () => {
|
|
74
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
75
|
+
style: 'currency',
|
|
76
|
+
currency,
|
|
77
|
+
});
|
|
78
|
+
const parts = formatter.formatToParts(0);
|
|
79
|
+
const symbolPart = parts.find(part => part.type === 'currency');
|
|
80
|
+
return symbolPart?.value || '$';
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const currencySymbol = getCurrencySymbol();
|
|
84
|
+
|
|
85
|
+
// Format number as currency
|
|
86
|
+
const formatCurrency = (num: number): string => {
|
|
87
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
88
|
+
minimumFractionDigits: isFocused ? 0 : precision,
|
|
89
|
+
maximumFractionDigits: precision,
|
|
90
|
+
});
|
|
91
|
+
return formatter.format(num);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Parse display value to number
|
|
95
|
+
const parseValue = (str: string): number | null => {
|
|
96
|
+
if (!str || str === '') return null;
|
|
97
|
+
|
|
98
|
+
// Remove all non-numeric characters except decimal point and minus sign
|
|
99
|
+
let cleaned = str.replace(/[^\d.-]/g, '');
|
|
100
|
+
|
|
101
|
+
// Handle multiple decimal points
|
|
102
|
+
const parts = cleaned.split('.');
|
|
103
|
+
if (parts.length > 2) {
|
|
104
|
+
cleaned = parts[0] + '.' + parts.slice(1).join('');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Handle multiple minus signs (keep only first)
|
|
108
|
+
const minusCount = (cleaned.match(/-/g) || []).length;
|
|
109
|
+
if (minusCount > 1) {
|
|
110
|
+
const hasLeadingMinus = cleaned.startsWith('-');
|
|
111
|
+
cleaned = cleaned.replace(/-/g, '');
|
|
112
|
+
if (hasLeadingMinus) cleaned = '-' + cleaned;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parse to float
|
|
116
|
+
const num = parseFloat(cleaned);
|
|
117
|
+
|
|
118
|
+
if (isNaN(num)) return null;
|
|
119
|
+
|
|
120
|
+
// Apply precision
|
|
121
|
+
return Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Update display value when external value changes
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (value === undefined || value === null || value === '') {
|
|
127
|
+
setDisplayValue('');
|
|
128
|
+
} else {
|
|
129
|
+
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
|
130
|
+
if (!isNaN(numValue)) {
|
|
131
|
+
setDisplayValue(formatCurrency(numValue));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}, [value, isFocused]);
|
|
135
|
+
|
|
136
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
137
|
+
const inputValue = e.target.value;
|
|
138
|
+
setDisplayValue(inputValue);
|
|
139
|
+
|
|
140
|
+
const numValue = parseValue(inputValue);
|
|
141
|
+
|
|
142
|
+
// Validate constraints
|
|
143
|
+
if (numValue !== null) {
|
|
144
|
+
if (!allowNegative && numValue < 0) return;
|
|
145
|
+
if (min !== undefined && numValue < min) return;
|
|
146
|
+
if (max !== undefined && numValue > max) return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
onChange?.(numValue);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
153
|
+
setIsFocused(true);
|
|
154
|
+
// Remove formatting when focused for easier editing
|
|
155
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
156
|
+
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
|
157
|
+
if (!isNaN(numValue)) {
|
|
158
|
+
setDisplayValue(numValue.toString());
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
onFocus?.(e);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
165
|
+
setIsFocused(false);
|
|
166
|
+
// Reformat on blur
|
|
167
|
+
const numValue = parseValue(displayValue);
|
|
168
|
+
if (numValue !== null) {
|
|
169
|
+
setDisplayValue(formatCurrency(numValue));
|
|
170
|
+
} else if (displayValue === '') {
|
|
171
|
+
setDisplayValue('');
|
|
172
|
+
}
|
|
173
|
+
onBlur?.(e);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<Input
|
|
178
|
+
ref={ref}
|
|
179
|
+
type="text"
|
|
180
|
+
value={displayValue}
|
|
181
|
+
onChange={handleChange}
|
|
182
|
+
onFocus={handleFocus}
|
|
183
|
+
onBlur={handleBlur}
|
|
184
|
+
prefix={currencySymbol}
|
|
185
|
+
{...props}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
CurrencyInput.displayName = 'CurrencyInput';
|
|
192
|
+
|
|
193
|
+
export default CurrencyInput;
|