@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.
Files changed (114) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +455 -455
  3. package/dist/components/Box.d.ts +2 -1
  4. package/dist/components/Box.d.ts.map +1 -1
  5. package/dist/components/Button.d.ts +10 -1
  6. package/dist/components/Button.d.ts.map +1 -1
  7. package/dist/components/Card.d.ts +11 -2
  8. package/dist/components/Card.d.ts.map +1 -1
  9. package/dist/components/CurrencyInput.d.ts +52 -0
  10. package/dist/components/CurrencyInput.d.ts.map +1 -0
  11. package/dist/components/DataTable.d.ts +19 -3
  12. package/dist/components/DataTable.d.ts.map +1 -1
  13. package/dist/components/EmptyState.d.ts +3 -1
  14. package/dist/components/EmptyState.d.ts.map +1 -1
  15. package/dist/components/Grid.d.ts +4 -2
  16. package/dist/components/Grid.d.ts.map +1 -1
  17. package/dist/components/Input.d.ts +2 -0
  18. package/dist/components/Input.d.ts.map +1 -1
  19. package/dist/components/Modal.d.ts.map +1 -1
  20. package/dist/components/MultiSelect.d.ts +13 -1
  21. package/dist/components/MultiSelect.d.ts.map +1 -1
  22. package/dist/components/Page.d.ts +2 -0
  23. package/dist/components/Page.d.ts.map +1 -1
  24. package/dist/components/PageLayout.d.ts +5 -1
  25. package/dist/components/PageLayout.d.ts.map +1 -1
  26. package/dist/components/Stack.d.ts +25 -5
  27. package/dist/components/Stack.d.ts.map +1 -1
  28. package/dist/components/Text.d.ts +20 -4
  29. package/dist/components/Text.d.ts.map +1 -1
  30. package/dist/components/Textarea.d.ts +2 -0
  31. package/dist/components/Textarea.d.ts.map +1 -1
  32. package/dist/components/index.d.ts +5 -3
  33. package/dist/components/index.d.ts.map +1 -1
  34. package/dist/index.d.ts +311 -49
  35. package/dist/index.esm.js +557 -224
  36. package/dist/index.esm.js.map +1 -1
  37. package/dist/index.js +555 -219
  38. package/dist/index.js.map +1 -1
  39. package/dist/styles.css +2838 -2679
  40. package/dist/utils/excelExport.d.ts +143 -0
  41. package/dist/utils/excelExport.d.ts.map +1 -0
  42. package/dist/utils/index.d.ts +2 -0
  43. package/dist/utils/index.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/components/AdminModal.css +49 -49
  46. package/src/components/Box.stories.tsx +377 -0
  47. package/src/components/Box.tsx +8 -4
  48. package/src/components/Button.tsx +23 -10
  49. package/src/components/Card.tsx +20 -5
  50. package/src/components/CurrencyInput.stories.tsx +290 -0
  51. package/src/components/CurrencyInput.tsx +193 -0
  52. package/src/components/DataTable.stories.tsx +36 -25
  53. package/src/components/DataTable.tsx +170 -16
  54. package/src/components/EmptyState.stories.tsx +124 -72
  55. package/src/components/EmptyState.tsx +10 -0
  56. package/src/components/Grid.stories.tsx +348 -0
  57. package/src/components/Grid.tsx +12 -5
  58. package/src/components/Input.tsx +12 -2
  59. package/src/components/Modal.stories.tsx +64 -0
  60. package/src/components/Modal.tsx +15 -2
  61. package/src/components/MultiSelect.tsx +41 -10
  62. package/src/components/Page.stories.tsx +76 -0
  63. package/src/components/Page.tsx +35 -3
  64. package/src/components/PageLayout.stories.tsx +75 -0
  65. package/src/components/PageLayout.tsx +28 -9
  66. package/src/components/RoleManager.css +10 -10
  67. package/src/components/Spreadsheet.css +216 -216
  68. package/src/components/Spreadsheet.stories.tsx +362 -362
  69. package/src/components/Spreadsheet.tsx +351 -351
  70. package/src/components/SpreadsheetSimple.stories.tsx +27 -27
  71. package/src/components/Stack.stories.tsx +24 -1
  72. package/src/components/Stack.tsx +40 -10
  73. package/src/components/Tabs.tsx +152 -152
  74. package/src/components/Text.stories.tsx +273 -0
  75. package/src/components/Text.tsx +33 -8
  76. package/src/components/Textarea.tsx +32 -21
  77. package/src/components/index.ts +6 -4
  78. package/src/styles/index.css +41 -4
  79. package/src/utils/excelExport.stories.tsx +535 -0
  80. package/src/utils/excelExport.ts +225 -0
  81. package/src/utils/index.ts +3 -0
  82. package/tailwind.config.js +253 -253
  83. package/dist/components/Button.stories.d.ts +0 -51
  84. package/dist/components/Button.stories.d.ts.map +0 -1
  85. package/dist/components/ChartVisualizationUI.d.ts +0 -21
  86. package/dist/components/ChartVisualizationUI.d.ts.map +0 -1
  87. package/dist/components/ChatUI.d.ts +0 -23
  88. package/dist/components/ChatUI.d.ts.map +0 -1
  89. package/dist/components/CommissionDashboardUI.d.ts +0 -25
  90. package/dist/components/CommissionDashboardUI.d.ts.map +0 -1
  91. package/dist/components/DataTable.stories.d.ts +0 -23
  92. package/dist/components/DataTable.stories.d.ts.map +0 -1
  93. package/dist/components/FormField.d.ts +0 -35
  94. package/dist/components/FormField.d.ts.map +0 -1
  95. package/dist/components/Input.stories.d.ts +0 -366
  96. package/dist/components/Input.stories.d.ts.map +0 -1
  97. package/dist/components/InsightsPanelUI.d.ts +0 -21
  98. package/dist/components/InsightsPanelUI.d.ts.map +0 -1
  99. package/dist/components/PaymentHistoryTimeline.d.ts +0 -34
  100. package/dist/components/PaymentHistoryTimeline.d.ts.map +0 -1
  101. package/dist/components/RelationshipManagerUI.d.ts +0 -60
  102. package/dist/components/RelationshipManagerUI.d.ts.map +0 -1
  103. package/dist/components/RoleManager.d.ts +0 -19
  104. package/dist/components/RoleManager.d.ts.map +0 -1
  105. package/dist/components/SplitCommissionBadge.d.ts +0 -18
  106. package/dist/components/SplitCommissionBadge.d.ts.map +0 -1
  107. package/dist/components/Spreadsheet.css +0 -216
  108. package/dist/components/Table.d.ts +0 -26
  109. package/dist/components/Table.d.ts.map +0 -1
  110. package/dist/components/__tests__/Button.test.d.ts +0 -2
  111. package/dist/components/__tests__/Button.test.d.ts.map +0 -1
  112. package/dist/components/__tests__/Input.test.d.ts +0 -2
  113. package/dist/components/__tests__/Input.test.d.ts.map +0 -1
  114. package/src/components/Table.tsx +0 -239
@@ -13,6 +13,8 @@ export interface EmptyStateProps {
13
13
  label: string;
14
14
  onClick: () => void;
15
15
  };
16
+ /** Optional custom content rendered below the description */
17
+ children?: React.ReactNode;
16
18
  }
17
19
 
18
20
  export default function EmptyState({
@@ -21,6 +23,7 @@ export default function EmptyState({
21
23
  description,
22
24
  action,
23
25
  secondaryAction,
26
+ children,
24
27
  }: EmptyStateProps) {
25
28
  return (
26
29
  <div className="flex flex-col items-center justify-center py-16 px-6 text-center">
@@ -37,6 +40,13 @@ export default function EmptyState({
37
40
  {/* Description */}
38
41
  <p className="text-sm text-ink-600 max-w-md mb-8">{description}</p>
39
42
 
43
+ {/* Custom children content */}
44
+ {children && (
45
+ <div className="mb-8 w-full max-w-md">
46
+ {children}
47
+ </div>
48
+ )}
49
+
40
50
  {/* Actions */}
41
51
  {(action || secondaryAction) && (
42
52
  <div className="flex items-center gap-3">
@@ -0,0 +1,348 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Grid } from './Grid';
3
+ import Box from './Box';
4
+ import Text from './Text';
5
+ import Card from './Card';
6
+
7
+ const meta = {
8
+ title: 'Layout/Grid',
9
+ component: Grid,
10
+ parameters: {
11
+ layout: 'padded',
12
+ docs: {
13
+ description: {
14
+ component: `
15
+ Grid component for arranging children in a CSS grid layout with responsive breakpoints.
16
+
17
+ ## Features
18
+ - **Column options**: 1, 2, 3, 4, 6, 12 columns
19
+ - **Responsive breakpoints**: columns (base), sm (640px+), md (768px+), lg (1024px+), xl (1280px+)
20
+ - **Gap spacing**: none, xs, sm, md, lg, xl
21
+ - **Mobile-first**: Set base columns and override at larger breakpoints
22
+
23
+ ## Usage
24
+
25
+ \`\`\`tsx
26
+ import { Grid } from 'notebook-ui';
27
+
28
+ // Simple 3-column grid
29
+ <Grid columns={3} gap="md">
30
+ <Card>Item 1</Card>
31
+ <Card>Item 2</Card>
32
+ <Card>Item 3</Card>
33
+ </Grid>
34
+
35
+ // Responsive grid: 1 column mobile, 2 tablet, 4 desktop
36
+ <Grid columns={1} md={2} lg={4} gap="md">
37
+ {items.map(item => <Card key={item.id}>{item.name}</Card>)}
38
+ </Grid>
39
+ \`\`\`
40
+ `,
41
+ },
42
+ },
43
+ },
44
+ tags: ['autodocs'],
45
+ argTypes: {
46
+ columns: {
47
+ control: 'select',
48
+ options: [1, 2, 3, 4, 6, 12],
49
+ description: 'Base number of columns',
50
+ },
51
+ sm: {
52
+ control: 'select',
53
+ options: [undefined, 1, 2, 3, 4, 6, 12],
54
+ description: 'Columns at sm breakpoint (640px+)',
55
+ },
56
+ md: {
57
+ control: 'select',
58
+ options: [undefined, 1, 2, 3, 4, 6, 12],
59
+ description: 'Columns at md breakpoint (768px+)',
60
+ },
61
+ lg: {
62
+ control: 'select',
63
+ options: [undefined, 1, 2, 3, 4, 6, 12],
64
+ description: 'Columns at lg breakpoint (1024px+)',
65
+ },
66
+ xl: {
67
+ control: 'select',
68
+ options: [undefined, 1, 2, 3, 4, 6, 12],
69
+ description: 'Columns at xl breakpoint (1280px+)',
70
+ },
71
+ gap: {
72
+ control: 'select',
73
+ options: ['none', 'xs', 'sm', 'md', 'lg', 'xl'],
74
+ description: 'Gap between grid items',
75
+ },
76
+ },
77
+ } satisfies Meta<typeof Grid>;
78
+
79
+ export default meta;
80
+ type Story = StoryObj<typeof meta>;
81
+
82
+ // Helper component for demo items
83
+ const DemoItem = ({ children }: { children: React.ReactNode }) => (
84
+ <Box padding="md" border="all" rounded="md" className="bg-paper-50">
85
+ <Text align="center">{children}</Text>
86
+ </Box>
87
+ );
88
+
89
+ export const Default: Story = {
90
+ args: {
91
+ columns: 3,
92
+ gap: 'md',
93
+ children: (
94
+ <>
95
+ <DemoItem>Item 1</DemoItem>
96
+ <DemoItem>Item 2</DemoItem>
97
+ <DemoItem>Item 3</DemoItem>
98
+ <DemoItem>Item 4</DemoItem>
99
+ <DemoItem>Item 5</DemoItem>
100
+ <DemoItem>Item 6</DemoItem>
101
+ </>
102
+ ),
103
+ },
104
+ };
105
+
106
+ export const TwoColumns: Story = {
107
+ args: {
108
+ columns: 2,
109
+ gap: 'md',
110
+ children: (
111
+ <>
112
+ <DemoItem>Left</DemoItem>
113
+ <DemoItem>Right</DemoItem>
114
+ <DemoItem>Left</DemoItem>
115
+ <DemoItem>Right</DemoItem>
116
+ </>
117
+ ),
118
+ },
119
+ };
120
+
121
+ export const FourColumns: Story = {
122
+ args: {
123
+ columns: 4,
124
+ gap: 'md',
125
+ children: (
126
+ <>
127
+ <DemoItem>1</DemoItem>
128
+ <DemoItem>2</DemoItem>
129
+ <DemoItem>3</DemoItem>
130
+ <DemoItem>4</DemoItem>
131
+ <DemoItem>5</DemoItem>
132
+ <DemoItem>6</DemoItem>
133
+ <DemoItem>7</DemoItem>
134
+ <DemoItem>8</DemoItem>
135
+ </>
136
+ ),
137
+ },
138
+ };
139
+
140
+ export const SixColumns: Story = {
141
+ args: {
142
+ columns: 6,
143
+ gap: 'sm',
144
+ children: (
145
+ <>
146
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((n) => (
147
+ <DemoItem key={n}>{n}</DemoItem>
148
+ ))}
149
+ </>
150
+ ),
151
+ },
152
+ };
153
+
154
+ export const TwelveColumns: Story = {
155
+ args: {
156
+ columns: 12,
157
+ gap: 'xs',
158
+ children: (
159
+ <>
160
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((n) => (
161
+ <DemoItem key={n}>{n}</DemoItem>
162
+ ))}
163
+ </>
164
+ ),
165
+ },
166
+ };
167
+
168
+ /**
169
+ * Responsive grid that changes columns based on screen size.
170
+ * Resize the browser to see it adapt.
171
+ */
172
+ export const Responsive: Story = {
173
+ args: {
174
+ columns: 1,
175
+ sm: 2,
176
+ md: 3,
177
+ lg: 4,
178
+ gap: 'md',
179
+ children: (
180
+ <>
181
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((n) => (
182
+ <DemoItem key={n}>Item {n}</DemoItem>
183
+ ))}
184
+ </>
185
+ ),
186
+ },
187
+ };
188
+
189
+ export const GapSizes: Story = {
190
+ render: () => (
191
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
192
+ <div>
193
+ <Text weight="medium" size="sm" color="muted">gap="none"</Text>
194
+ <Grid columns={4} gap="none">
195
+ <DemoItem>1</DemoItem>
196
+ <DemoItem>2</DemoItem>
197
+ <DemoItem>3</DemoItem>
198
+ <DemoItem>4</DemoItem>
199
+ </Grid>
200
+ </div>
201
+ <div>
202
+ <Text weight="medium" size="sm" color="muted">gap="xs"</Text>
203
+ <Grid columns={4} gap="xs">
204
+ <DemoItem>1</DemoItem>
205
+ <DemoItem>2</DemoItem>
206
+ <DemoItem>3</DemoItem>
207
+ <DemoItem>4</DemoItem>
208
+ </Grid>
209
+ </div>
210
+ <div>
211
+ <Text weight="medium" size="sm" color="muted">gap="sm"</Text>
212
+ <Grid columns={4} gap="sm">
213
+ <DemoItem>1</DemoItem>
214
+ <DemoItem>2</DemoItem>
215
+ <DemoItem>3</DemoItem>
216
+ <DemoItem>4</DemoItem>
217
+ </Grid>
218
+ </div>
219
+ <div>
220
+ <Text weight="medium" size="sm" color="muted">gap="md" (default)</Text>
221
+ <Grid columns={4} gap="md">
222
+ <DemoItem>1</DemoItem>
223
+ <DemoItem>2</DemoItem>
224
+ <DemoItem>3</DemoItem>
225
+ <DemoItem>4</DemoItem>
226
+ </Grid>
227
+ </div>
228
+ <div>
229
+ <Text weight="medium" size="sm" color="muted">gap="lg"</Text>
230
+ <Grid columns={4} gap="lg">
231
+ <DemoItem>1</DemoItem>
232
+ <DemoItem>2</DemoItem>
233
+ <DemoItem>3</DemoItem>
234
+ <DemoItem>4</DemoItem>
235
+ </Grid>
236
+ </div>
237
+ <div>
238
+ <Text weight="medium" size="sm" color="muted">gap="xl"</Text>
239
+ <Grid columns={4} gap="xl">
240
+ <DemoItem>1</DemoItem>
241
+ <DemoItem>2</DemoItem>
242
+ <DemoItem>3</DemoItem>
243
+ <DemoItem>4</DemoItem>
244
+ </Grid>
245
+ </div>
246
+ </div>
247
+ ),
248
+ };
249
+
250
+ export const WithCards: Story = {
251
+ render: () => (
252
+ <Grid columns={1} md={2} lg={3} gap="md">
253
+ <Card>
254
+ <Card.Header>
255
+ <Card.Title>Card 1</Card.Title>
256
+ </Card.Header>
257
+ <Card.Content>
258
+ <Text color="secondary">Content for the first card.</Text>
259
+ </Card.Content>
260
+ </Card>
261
+ <Card>
262
+ <Card.Header>
263
+ <Card.Title>Card 2</Card.Title>
264
+ </Card.Header>
265
+ <Card.Content>
266
+ <Text color="secondary">Content for the second card.</Text>
267
+ </Card.Content>
268
+ </Card>
269
+ <Card>
270
+ <Card.Header>
271
+ <Card.Title>Card 3</Card.Title>
272
+ </Card.Header>
273
+ <Card.Content>
274
+ <Text color="secondary">Content for the third card.</Text>
275
+ </Card.Content>
276
+ </Card>
277
+ </Grid>
278
+ ),
279
+ };
280
+
281
+ export const DashboardLayout: Story = {
282
+ render: () => (
283
+ <Grid columns={1} md={2} lg={4} gap="md">
284
+ <Box padding="lg" border="all" rounded="lg" className="bg-success-50">
285
+ <Text size="sm" color="muted">Revenue</Text>
286
+ <Text size="2xl" weight="bold">$45,231</Text>
287
+ <Text size="sm" color="success">+20.1% from last month</Text>
288
+ </Box>
289
+ <Box padding="lg" border="all" rounded="lg" className="bg-primary-50">
290
+ <Text size="sm" color="muted">Users</Text>
291
+ <Text size="2xl" weight="bold">2,350</Text>
292
+ <Text size="sm" color="accent">+180 new this week</Text>
293
+ </Box>
294
+ <Box padding="lg" border="all" rounded="lg" className="bg-warning-50">
295
+ <Text size="sm" color="muted">Pending</Text>
296
+ <Text size="2xl" weight="bold">12</Text>
297
+ <Text size="sm" color="warning">Requires attention</Text>
298
+ </Box>
299
+ <Box padding="lg" border="all" rounded="lg" className="bg-error-50">
300
+ <Text size="sm" color="muted">Errors</Text>
301
+ <Text size="2xl" weight="bold">3</Text>
302
+ <Text size="sm" color="error">Critical issues</Text>
303
+ </Box>
304
+ </Grid>
305
+ ),
306
+ };
307
+
308
+ export const FormLayout: Story = {
309
+ render: () => (
310
+ <Box padding="lg" border="all" rounded="lg" style={{ maxWidth: '600px' }}>
311
+ <Text as="h3" size="lg" weight="semibold">User Details</Text>
312
+ <Box marginTop="md">
313
+ <Grid columns={1} md={2} gap="md">
314
+ <Box padding="sm" border="all" rounded="sm" className="bg-paper-50">
315
+ <Text size="sm" color="muted">First Name</Text>
316
+ </Box>
317
+ <Box padding="sm" border="all" rounded="sm" className="bg-paper-50">
318
+ <Text size="sm" color="muted">Last Name</Text>
319
+ </Box>
320
+ <Box padding="sm" border="all" rounded="sm" className="bg-paper-50">
321
+ <Text size="sm" color="muted">Email</Text>
322
+ </Box>
323
+ <Box padding="sm" border="all" rounded="sm" className="bg-paper-50">
324
+ <Text size="sm" color="muted">Phone</Text>
325
+ </Box>
326
+ </Grid>
327
+ </Box>
328
+ </Box>
329
+ ),
330
+ };
331
+
332
+ export const ImageGallery: Story = {
333
+ render: () => (
334
+ <Grid columns={2} md={3} lg={4} gap="sm">
335
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((n) => (
336
+ <Box
337
+ key={n}
338
+ padding="none"
339
+ rounded="md"
340
+ className="bg-paper-200 aspect-square flex items-center justify-center"
341
+ style={{ aspectRatio: '1/1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
342
+ >
343
+ <Text color="muted">Image {n}</Text>
344
+ </Box>
345
+ ))}
346
+ </Grid>
347
+ ),
348
+ };
@@ -1,11 +1,11 @@
1
1
  // Grid Component - CSS Grid layout system
2
2
  // Provides flexible grid layouts with consistent spacing
3
3
 
4
- import React from 'react';
4
+ import React, { forwardRef } from 'react';
5
5
 
6
6
  type ColumnCount = 1 | 2 | 3 | 4 | 6 | 12;
7
7
 
8
- export interface GridProps {
8
+ export interface GridProps extends React.HTMLAttributes<HTMLDivElement> {
9
9
  /** Content to arrange in grid */
10
10
  children: React.ReactNode;
11
11
  /** Number of columns (default, or mobile-first base) */
@@ -26,6 +26,8 @@ export interface GridProps {
26
26
 
27
27
  /**
28
28
  * Grid component for arranging children in a CSS grid layout.
29
+ *
30
+ * Supports ref forwarding for DOM access.
29
31
  *
30
32
  * Column options: 1, 2, 3, 4, 6, 12
31
33
  *
@@ -51,7 +53,7 @@ export interface GridProps {
51
53
  * <Card>Item 2</Card>
52
54
  * </Grid>
53
55
  */
54
- export const Grid: React.FC<GridProps> = ({
56
+ export const Grid = forwardRef<HTMLDivElement, GridProps>(({
55
57
  children,
56
58
  columns = 1,
57
59
  sm,
@@ -60,7 +62,8 @@ export const Grid: React.FC<GridProps> = ({
60
62
  xl,
61
63
  gap = 'md',
62
64
  className = '',
63
- }) => {
65
+ ...htmlProps
66
+ }, ref) => {
64
67
  // Base column classes
65
68
  const baseColumnClasses: Record<ColumnCount, string> = {
66
69
  1: 'grid-cols-1',
@@ -128,6 +131,8 @@ export const Grid: React.FC<GridProps> = ({
128
131
 
129
132
  return (
130
133
  <div
134
+ ref={ref}
135
+ {...htmlProps}
131
136
  className={`
132
137
  grid
133
138
  ${columnClassList}
@@ -138,6 +143,8 @@ export const Grid: React.FC<GridProps> = ({
138
143
  {children}
139
144
  </div>
140
145
  );
141
- };
146
+ });
147
+
148
+ Grid.displayName = 'Grid';
142
149
 
143
150
  export default Grid;
@@ -1,5 +1,5 @@
1
1
  import React, { forwardRef, useState } from 'react';
2
- import { AlertCircle, CheckCircle, AlertTriangle, Eye, EyeOff, X } from 'lucide-react';
2
+ import { AlertCircle, CheckCircle, AlertTriangle, Eye, EyeOff, X, Loader2 } from 'lucide-react';
3
3
 
4
4
  /**
5
5
  * Validation state for input components
@@ -38,6 +38,8 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
38
38
  clearable?: boolean;
39
39
  /** Callback when clear button is clicked */
40
40
  onClear?: () => void;
41
+ /** Show loading spinner in input */
42
+ loading?: boolean;
41
43
  }
42
44
 
43
45
  /**
@@ -105,6 +107,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
105
107
  showPasswordToggle = false,
106
108
  clearable = false,
107
109
  onClear,
110
+ loading = false,
108
111
  className = '',
109
112
  id,
110
113
  type = 'text',
@@ -245,8 +248,15 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
245
248
 
246
249
  {/* Right Icon, Validation Icon, Clear Button, or Password Toggle */}
247
250
  <div className="absolute inset-y-0 right-0 pr-3 flex items-center gap-2">
251
+ {/* Loading Spinner */}
252
+ {loading && (
253
+ <div className="pointer-events-none text-ink-400">
254
+ <Loader2 className="h-5 w-5 animate-spin" />
255
+ </div>
256
+ )}
257
+
248
258
  {/* Suffix Icon */}
249
- {suffixIcon && !suffix && !validationState && !showPasswordToggle && !showClearButton && (
259
+ {suffixIcon && !suffix && !validationState && !showPasswordToggle && !showClearButton && !loading && (
250
260
  <div className="pointer-events-none text-ink-400">
251
261
  {suffixIcon}
252
262
  </div>
@@ -284,3 +284,67 @@ export const NoCloseButton: Story = {
284
284
  );
285
285
  },
286
286
  };
287
+
288
+ export const TextSelectionTest: Story = {
289
+ render: () => {
290
+ const [isOpen, setIsOpen] = useState(false);
291
+ return (
292
+ <>
293
+ <Button onClick={() => setIsOpen(true)}>Test Text Selection</Button>
294
+ <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Text Selection Test">
295
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
296
+ <div style={{
297
+ padding: '1rem',
298
+ backgroundColor: '#dbeafe',
299
+ border: '1px solid #3b82f6',
300
+ borderRadius: '0.375rem'
301
+ }}>
302
+ <h4 style={{ fontWeight: 600, marginBottom: '0.5rem', fontSize: '0.875rem', color: '#1e40af' }}>
303
+ ✅ Bug Fix Test: Text Selection
304
+ </h4>
305
+ <p style={{ fontSize: '0.875rem' }}>
306
+ Try selecting text in the input fields below by clicking and dragging <strong>outside</strong> the modal boundaries.
307
+ The modal should <strong>NOT</strong> close when you release the mouse button outside.
308
+ </p>
309
+ </div>
310
+ <Input
311
+ label="Full Name"
312
+ placeholder="Try selecting this text and dragging outside the modal"
313
+ defaultValue="John Smith - Drag to select and move mouse outside modal bounds"
314
+ />
315
+ <Input
316
+ label="Email Address"
317
+ type="email"
318
+ placeholder="user@example.com"
319
+ defaultValue="test.user@example.com - Select text here too"
320
+ />
321
+ <Input
322
+ label="Company"
323
+ placeholder="Company name"
324
+ defaultValue="This is a long company name that you can select by dragging"
325
+ />
326
+ <div style={{
327
+ padding: '0.75rem',
328
+ backgroundColor: '#fef3c7',
329
+ border: '1px solid #f59e0b',
330
+ borderRadius: '0.375rem',
331
+ fontSize: '0.875rem'
332
+ }}>
333
+ <strong>Test Instructions:</strong>
334
+ <ol style={{ marginLeft: '1.5rem', marginTop: '0.5rem' }}>
335
+ <li>Click and hold inside any input field</li>
336
+ <li>While holding, drag your mouse outside the modal</li>
337
+ <li>Release the mouse button while outside</li>
338
+ <li>Modal should remain open! ✅</li>
339
+ </ol>
340
+ </div>
341
+ </div>
342
+ <ModalFooter>
343
+ <Button variant="ghost" onClick={() => setIsOpen(false)}>Cancel</Button>
344
+ <Button variant="primary" onClick={() => setIsOpen(false)}>Save</Button>
345
+ </ModalFooter>
346
+ </Modal>
347
+ </>
348
+ );
349
+ },
350
+ };
@@ -30,6 +30,7 @@ export default function Modal({
30
30
  animation = 'scale',
31
31
  }: ModalProps) {
32
32
  const modalRef = useRef<HTMLDivElement>(null);
33
+ const mouseDownOnBackdrop = useRef(false);
33
34
  const titleId = useId();
34
35
 
35
36
  // Handle escape key
@@ -51,11 +52,22 @@ export default function Modal({
51
52
  };
52
53
  }, [isOpen, onClose]);
53
54
 
54
- // Handle click outside
55
- const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
55
+ // Track if mousedown originated on the backdrop
56
+ const handleBackdropMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
56
57
  if (e.target === e.currentTarget) {
58
+ mouseDownOnBackdrop.current = true;
59
+ } else {
60
+ mouseDownOnBackdrop.current = false;
61
+ }
62
+ };
63
+
64
+ // Handle click outside - only close if both mousedown and click happened on backdrop
65
+ const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
66
+ if (e.target === e.currentTarget && mouseDownOnBackdrop.current) {
57
67
  onClose();
58
68
  }
69
+ // Reset the flag after handling click
70
+ mouseDownOnBackdrop.current = false;
59
71
  };
60
72
 
61
73
  const getAnimationClass = () => {
@@ -80,6 +92,7 @@ export default function Modal({
80
92
  return (
81
93
  <div
82
94
  className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-ink-900 bg-opacity-50 backdrop-blur-sm animate-fade-in"
95
+ onMouseDown={handleBackdropMouseDown}
83
96
  onClick={handleBackdropClick}
84
97
  >
85
98
  <div
@@ -1,5 +1,5 @@
1
- import React, { useState, useRef, useEffect } from 'react';
2
- import { Check, ChevronDown, Search, X } from 'lucide-react';
1
+ import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
2
+ import { Check, ChevronDown, Search, X, Loader2 } from 'lucide-react';
3
3
 
4
4
  export interface MultiSelectOption {
5
5
  value: string;
@@ -21,10 +21,22 @@ export interface MultiSelectProps {
21
21
  maxHeight?: number;
22
22
  /** Maximum number of selections allowed */
23
23
  maxSelections?: number;
24
+ /** Show loading spinner (for async options loading) */
25
+ loading?: boolean;
24
26
  'aria-label'?: string;
25
27
  }
26
28
 
27
- export default function MultiSelect({
29
+ /** Handle for imperative methods */
30
+ export interface MultiSelectHandle {
31
+ /** Focus the select trigger button */
32
+ focus: () => void;
33
+ /** Open the dropdown */
34
+ open: () => void;
35
+ /** Close the dropdown */
36
+ close: () => void;
37
+ }
38
+
39
+ const MultiSelect = forwardRef<MultiSelectHandle, MultiSelectProps>(({
28
40
  options,
29
41
  value = [],
30
42
  onChange,
@@ -36,12 +48,21 @@ export default function MultiSelect({
36
48
  error,
37
49
  maxHeight = 240,
38
50
  maxSelections,
51
+ loading = false,
39
52
  'aria-label': ariaLabel,
40
- }: MultiSelectProps) {
53
+ }, ref) => {
41
54
  const [isOpen, setIsOpen] = useState(false);
42
55
  const [searchQuery, setSearchQuery] = useState('');
43
56
  const selectRef = useRef<HTMLDivElement>(null);
44
57
  const searchInputRef = useRef<HTMLInputElement>(null);
58
+ const triggerRef = useRef<HTMLButtonElement>(null);
59
+
60
+ // Expose imperative methods
61
+ useImperativeHandle(ref, () => ({
62
+ focus: () => triggerRef.current?.focus(),
63
+ open: () => !disabled && setIsOpen(true),
64
+ close: () => setIsOpen(false),
65
+ }));
45
66
 
46
67
  const selectedOptions = options.filter(opt => value.includes(opt.value));
47
68
  const hasReachedMax = maxSelections ? value.length >= maxSelections : false;
@@ -112,13 +133,14 @@ export default function MultiSelect({
112
133
  <div ref={selectRef} className="relative">
113
134
  {/* Trigger Button */}
114
135
  <button
136
+ ref={triggerRef}
115
137
  type="button"
116
- onClick={() => !disabled && setIsOpen(!isOpen)}
117
- disabled={disabled}
138
+ onClick={() => !disabled && !loading && setIsOpen(!isOpen)}
139
+ disabled={disabled || loading}
118
140
  className={`
119
141
  input w-full flex items-center justify-between min-h-[42px]
120
142
  ${error ? 'border-error-400 focus:border-error-400 focus:ring-error-400' : ''}
121
- ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
143
+ ${disabled || loading ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
122
144
  `}
123
145
  aria-haspopup="listbox"
124
146
  aria-expanded={isOpen}
@@ -148,7 +170,10 @@ export default function MultiSelect({
148
170
  )}
149
171
  </div>
150
172
  <div className="flex items-center gap-2 ml-2">
151
- {selectedOptions.length > 0 && !disabled && (
173
+ {loading && (
174
+ <Loader2 className="h-4 w-4 text-ink-400 animate-spin" />
175
+ )}
176
+ {!loading && selectedOptions.length > 0 && !disabled && (
152
177
  <button
153
178
  type="button"
154
179
  onClick={handleClearAll}
@@ -158,7 +183,9 @@ export default function MultiSelect({
158
183
  <X className="h-4 w-4" />
159
184
  </button>
160
185
  )}
161
- <ChevronDown className={`h-4 w-4 text-ink-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
186
+ {!loading && (
187
+ <ChevronDown className={`h-4 w-4 text-ink-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
188
+ )}
162
189
  </div>
163
190
  </button>
164
191
 
@@ -244,4 +271,8 @@ export default function MultiSelect({
244
271
  </div>
245
272
  </div>
246
273
  );
247
- }
274
+ });
275
+
276
+ MultiSelect.displayName = 'MultiSelect';
277
+
278
+ export default MultiSelect;