@papernote/ui 1.10.6 → 1.10.7
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/SwipeableListItem.d.ts +51 -32
- package/dist/components/SwipeableListItem.d.ts.map +1 -1
- package/dist/index.d.ts +51 -32
- package/dist/index.esm.js +232 -148
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +232 -148
- package/dist/index.js.map +1 -1
- package/dist/styles.css +135 -0
- package/package.json +1 -1
- package/src/components/SwipeableListItem.stories.tsx +366 -294
- package/src/components/SwipeableListItem.tsx +297 -202
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
3
|
import { SwipeableListItem } from './SwipeableListItem';
|
|
4
|
-
import { Check, X, Archive, Trash, Star, Mail, MailOpen } from 'lucide-react';
|
|
4
|
+
import { Check, X, Archive, Trash, Star, Mail, MailOpen, Flag, Clock, Pin, Bell, BellOff, Edit, MoreHorizontal } from 'lucide-react';
|
|
5
5
|
import Stack from './Stack';
|
|
6
6
|
import Text from './Text';
|
|
7
7
|
import Badge from './Badge';
|
|
@@ -14,25 +14,28 @@ const meta: Meta<typeof SwipeableListItem> = {
|
|
|
14
14
|
docs: {
|
|
15
15
|
description: {
|
|
16
16
|
component: `
|
|
17
|
-
A list item component with swipe-to-action
|
|
17
|
+
A list item component with swipe-to-reveal action buttons for mobile touch gestures.
|
|
18
18
|
|
|
19
19
|
## Features
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
20
|
+
- **Multiple actions per side** - Like email apps, reveal multiple action buttons
|
|
21
|
+
- **Full swipe mode** - Swipe all the way to trigger primary action
|
|
22
|
+
- **Touch & mouse support** - Works on mobile and desktop
|
|
23
|
+
- **Keyboard accessible** - Arrow keys, Tab, Enter, Escape
|
|
24
|
+
- **Async support** - Loading states for async operations
|
|
25
|
+
- **Haptic feedback** - Vibration on mobile devices
|
|
26
|
+
- **Polished visuals** - Gradients, shadows, smooth animations
|
|
25
27
|
|
|
26
|
-
##
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
28
|
+
## Keyboard Navigation
|
|
29
|
+
- **Arrow Left/Right** - Open action panels
|
|
30
|
+
- **Tab** - Navigate between actions
|
|
31
|
+
- **Enter/Space** - Execute focused action
|
|
32
|
+
- **Escape** - Close and reset
|
|
30
33
|
|
|
31
34
|
## Use Cases
|
|
32
|
-
- Email/message lists (mark read, delete, archive)
|
|
33
|
-
- Transaction approvals (approve, reject)
|
|
34
|
-
- Todo lists (complete, delete)
|
|
35
|
-
- Notification management (dismiss,
|
|
35
|
+
- Email/message lists (mark read, delete, archive, flag)
|
|
36
|
+
- Transaction approvals (approve, reject, defer)
|
|
37
|
+
- Todo lists (complete, delete, snooze)
|
|
38
|
+
- Notification management (dismiss, mute, pin)
|
|
36
39
|
`,
|
|
37
40
|
},
|
|
38
41
|
},
|
|
@@ -44,16 +47,6 @@ A list item component with swipe-to-action functionality for mobile touch gestur
|
|
|
44
47
|
</div>
|
|
45
48
|
),
|
|
46
49
|
],
|
|
47
|
-
argTypes: {
|
|
48
|
-
swipeThreshold: {
|
|
49
|
-
control: { type: 'range', min: 50, max: 200, step: 10 },
|
|
50
|
-
description: 'Pixels of swipe before action triggers',
|
|
51
|
-
},
|
|
52
|
-
disabled: {
|
|
53
|
-
control: 'boolean',
|
|
54
|
-
description: 'Disable swipe interactions',
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
50
|
};
|
|
58
51
|
|
|
59
52
|
export default meta;
|
|
@@ -63,25 +56,34 @@ type Story = StoryObj<typeof SwipeableListItem>;
|
|
|
63
56
|
const ListItemContent = ({
|
|
64
57
|
title,
|
|
65
58
|
subtitle,
|
|
66
|
-
badge
|
|
59
|
+
badge,
|
|
60
|
+
avatar,
|
|
67
61
|
}: {
|
|
68
62
|
title: string;
|
|
69
63
|
subtitle: string;
|
|
70
64
|
badge?: string;
|
|
65
|
+
avatar?: string;
|
|
71
66
|
}) => (
|
|
72
|
-
<div className="p-4 border-b border-paper-200">
|
|
73
|
-
<Stack direction="horizontal"
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
|
|
67
|
+
<div className="p-4 border-b border-paper-200 bg-white">
|
|
68
|
+
<Stack direction="horizontal" gap="md" align="center">
|
|
69
|
+
{avatar && (
|
|
70
|
+
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-accent-400 to-accent-600 flex items-center justify-center text-white font-medium">
|
|
71
|
+
{avatar}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
<Stack gap="xs" className="flex-1 min-w-0">
|
|
75
|
+
<Stack direction="horizontal" justify="between" align="center">
|
|
76
|
+
<Text weight="medium" className="truncate">{title}</Text>
|
|
77
|
+
{badge && <Badge variant="info" size="sm">{badge}</Badge>}
|
|
78
|
+
</Stack>
|
|
79
|
+
<Text size="sm" className="text-ink-500 truncate">{subtitle}</Text>
|
|
77
80
|
</Stack>
|
|
78
|
-
{badge && <Badge variant="info">{badge}</Badge>}
|
|
79
81
|
</Stack>
|
|
80
82
|
</div>
|
|
81
83
|
);
|
|
82
84
|
|
|
83
85
|
/**
|
|
84
|
-
* Default
|
|
86
|
+
* Default with single action per side
|
|
85
87
|
*/
|
|
86
88
|
export const Default: Story = {
|
|
87
89
|
render: () => {
|
|
@@ -91,33 +93,35 @@ export const Default: Story = {
|
|
|
91
93
|
{ id: 3, title: 'Categorize transaction', subtitle: '$89.99 - Unknown merchant' },
|
|
92
94
|
]);
|
|
93
95
|
|
|
94
|
-
const handleApprove = (id: number) => {
|
|
95
|
-
console.log('Approved:', id);
|
|
96
|
-
setItems(items.filter(item => item.id !== id));
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const handleDismiss = (id: number) => {
|
|
100
|
-
console.log('Dismissed:', id);
|
|
101
|
-
setItems(items.filter(item => item.id !== id));
|
|
102
|
-
};
|
|
103
|
-
|
|
104
96
|
return (
|
|
105
|
-
<Stack gap="none">
|
|
97
|
+
<Stack gap="none" className="border border-paper-200 rounded-lg overflow-hidden">
|
|
106
98
|
{items.map((item) => (
|
|
107
99
|
<SwipeableListItem
|
|
108
100
|
key={item.id}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
101
|
+
rightActions={[
|
|
102
|
+
{
|
|
103
|
+
id: 'approve',
|
|
104
|
+
icon: Check,
|
|
105
|
+
color: 'success',
|
|
106
|
+
label: 'Approve',
|
|
107
|
+
onClick: () => {
|
|
108
|
+
console.log('Approved:', item.id);
|
|
109
|
+
setItems(items.filter(i => i.id !== item.id));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
]}
|
|
113
|
+
leftActions={[
|
|
114
|
+
{
|
|
115
|
+
id: 'dismiss',
|
|
116
|
+
icon: X,
|
|
117
|
+
color: 'destructive',
|
|
118
|
+
label: 'Dismiss',
|
|
119
|
+
onClick: () => {
|
|
120
|
+
console.log('Dismissed:', item.id);
|
|
121
|
+
setItems(items.filter(i => i.id !== item.id));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
]}
|
|
121
125
|
>
|
|
122
126
|
<ListItemContent title={item.title} subtitle={item.subtitle} />
|
|
123
127
|
</SwipeableListItem>
|
|
@@ -133,82 +137,202 @@ export const Default: Story = {
|
|
|
133
137
|
};
|
|
134
138
|
|
|
135
139
|
/**
|
|
136
|
-
*
|
|
140
|
+
* Multiple actions per side (email-style)
|
|
137
141
|
*/
|
|
138
|
-
export const
|
|
139
|
-
render: () =>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
142
|
+
export const MultipleActions: Story = {
|
|
143
|
+
render: () => {
|
|
144
|
+
const [emails, setEmails] = useState([
|
|
145
|
+
{ id: 1, from: 'JD', name: 'John Doe', subject: 'Q4 Budget Review Meeting', time: '10:30 AM', unread: true, starred: false },
|
|
146
|
+
{ id: 2, from: 'TS', name: 'Team Slack', subject: 'New message in #general', time: '9:15 AM', unread: true, starred: false },
|
|
147
|
+
{ id: 3, from: 'HR', name: 'HR Department', subject: 'Holiday Schedule Update', time: 'Yesterday', unread: false, starred: true },
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const toggleStar = (id: number) => {
|
|
151
|
+
setEmails(emails.map(e => e.id === id ? { ...e, starred: !e.starred } : e));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const markRead = (id: number) => {
|
|
155
|
+
setEmails(emails.map(e => e.id === id ? { ...e, unread: false } : e));
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<Stack gap="none" className="border border-paper-200 rounded-lg overflow-hidden shadow-sm">
|
|
160
|
+
<div className="bg-paper-100 px-4 py-2 border-b border-paper-200">
|
|
161
|
+
<Text size="sm" weight="medium" className="text-ink-600">Inbox</Text>
|
|
162
|
+
</div>
|
|
163
|
+
{emails.map((email) => (
|
|
164
|
+
<SwipeableListItem
|
|
165
|
+
key={email.id}
|
|
166
|
+
rightActions={[
|
|
167
|
+
{
|
|
168
|
+
id: 'read',
|
|
169
|
+
icon: email.unread ? MailOpen : Mail,
|
|
170
|
+
color: 'primary',
|
|
171
|
+
label: email.unread ? 'Read' : 'Unread',
|
|
172
|
+
onClick: () => markRead(email.id)
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'star',
|
|
176
|
+
icon: Star,
|
|
177
|
+
color: 'warning',
|
|
178
|
+
label: email.starred ? 'Unstar' : 'Star',
|
|
179
|
+
onClick: () => toggleStar(email.id)
|
|
180
|
+
},
|
|
181
|
+
]}
|
|
182
|
+
leftActions={[
|
|
183
|
+
{
|
|
184
|
+
id: 'delete',
|
|
185
|
+
icon: Trash,
|
|
186
|
+
color: 'destructive',
|
|
187
|
+
label: 'Delete',
|
|
188
|
+
onClick: () => setEmails(emails.filter(e => e.id !== email.id))
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'archive',
|
|
192
|
+
icon: Archive,
|
|
193
|
+
color: 'neutral',
|
|
194
|
+
label: 'Archive',
|
|
195
|
+
onClick: () => setEmails(emails.filter(e => e.id !== email.id))
|
|
196
|
+
},
|
|
197
|
+
]}
|
|
198
|
+
>
|
|
199
|
+
<div className="p-4 border-b border-paper-100 bg-white">
|
|
200
|
+
<Stack direction="horizontal" gap="md" align="start">
|
|
201
|
+
<div className={`
|
|
202
|
+
w-10 h-10 rounded-full flex items-center justify-center text-white font-medium text-sm
|
|
203
|
+
${email.unread ? 'bg-gradient-to-br from-accent-500 to-accent-700' : 'bg-paper-400'}
|
|
204
|
+
`}>
|
|
205
|
+
{email.from}
|
|
206
|
+
</div>
|
|
207
|
+
<Stack gap="xs" className="flex-1 min-w-0">
|
|
208
|
+
<Stack direction="horizontal" justify="between" align="center">
|
|
209
|
+
<Stack direction="horizontal" gap="sm" align="center">
|
|
210
|
+
{email.unread && (
|
|
211
|
+
<div className="w-2 h-2 rounded-full bg-accent-500" />
|
|
212
|
+
)}
|
|
213
|
+
<Text weight={email.unread ? 'semibold' : 'normal'} className="truncate">
|
|
214
|
+
{email.name}
|
|
215
|
+
</Text>
|
|
216
|
+
</Stack>
|
|
217
|
+
<Stack direction="horizontal" gap="sm" align="center">
|
|
218
|
+
{email.starred && <Star className="h-4 w-4 text-warning-500 fill-warning-500" />}
|
|
219
|
+
<Text size="xs" className="text-ink-400">{email.time}</Text>
|
|
220
|
+
</Stack>
|
|
221
|
+
</Stack>
|
|
222
|
+
<Text size="sm" className={email.unread ? 'text-ink-700' : 'text-ink-400'}>
|
|
223
|
+
{email.subject}
|
|
224
|
+
</Text>
|
|
225
|
+
</Stack>
|
|
226
|
+
</Stack>
|
|
227
|
+
</div>
|
|
228
|
+
</SwipeableListItem>
|
|
229
|
+
))}
|
|
230
|
+
</Stack>
|
|
231
|
+
);
|
|
232
|
+
},
|
|
155
233
|
};
|
|
156
234
|
|
|
157
235
|
/**
|
|
158
|
-
*
|
|
236
|
+
* Full swipe to trigger action
|
|
159
237
|
*/
|
|
160
|
-
export const
|
|
161
|
-
render: () =>
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
238
|
+
export const FullSwipe: Story = {
|
|
239
|
+
render: () => {
|
|
240
|
+
const [todos, setTodos] = useState([
|
|
241
|
+
{ id: 1, text: 'Review pull request', done: false },
|
|
242
|
+
{ id: 2, text: 'Update documentation', done: false },
|
|
243
|
+
{ id: 3, text: 'Fix CI pipeline', done: false },
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<Stack gap="md">
|
|
248
|
+
<div className="p-3 bg-success-50 border border-success-200 rounded-lg">
|
|
249
|
+
<Text size="sm" className="text-success-800">
|
|
250
|
+
<strong>Full swipe enabled:</strong> Swipe all the way right to complete, or all the way left to delete.
|
|
251
|
+
</Text>
|
|
252
|
+
</div>
|
|
253
|
+
<Stack gap="none" className="border border-paper-200 rounded-lg overflow-hidden">
|
|
254
|
+
{todos.map((todo) => (
|
|
255
|
+
<SwipeableListItem
|
|
256
|
+
key={todo.id}
|
|
257
|
+
fullSwipe
|
|
258
|
+
fullSwipeThreshold={0.4}
|
|
259
|
+
rightActions={[
|
|
260
|
+
{
|
|
261
|
+
id: 'complete',
|
|
262
|
+
icon: Check,
|
|
263
|
+
color: 'success',
|
|
264
|
+
label: 'Done',
|
|
265
|
+
onClick: async () => {
|
|
266
|
+
await new Promise(r => setTimeout(r, 500));
|
|
267
|
+
setTodos(todos.filter(t => t.id !== todo.id));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
]}
|
|
271
|
+
leftActions={[
|
|
272
|
+
{
|
|
273
|
+
id: 'delete',
|
|
274
|
+
icon: Trash,
|
|
275
|
+
color: 'destructive',
|
|
276
|
+
label: 'Delete',
|
|
277
|
+
onClick: () => setTodos(todos.filter(t => t.id !== todo.id))
|
|
278
|
+
}
|
|
279
|
+
]}
|
|
280
|
+
>
|
|
281
|
+
<div className="p-4 border-b border-paper-100 bg-white">
|
|
282
|
+
<Stack direction="horizontal" gap="md" align="center">
|
|
283
|
+
<div className="w-5 h-5 rounded border-2 border-paper-300" />
|
|
284
|
+
<Text>{todo.text}</Text>
|
|
285
|
+
</Stack>
|
|
286
|
+
</div>
|
|
287
|
+
</SwipeableListItem>
|
|
288
|
+
))}
|
|
289
|
+
{todos.length === 0 && (
|
|
290
|
+
<div className="p-8 text-center text-ink-400">
|
|
291
|
+
All tasks completed! 🎉
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
</Stack>
|
|
295
|
+
</Stack>
|
|
296
|
+
);
|
|
297
|
+
},
|
|
176
298
|
};
|
|
177
299
|
|
|
178
300
|
/**
|
|
179
|
-
* With async
|
|
301
|
+
* With async loading states
|
|
180
302
|
*/
|
|
181
|
-
export const
|
|
303
|
+
export const AsyncActions: Story = {
|
|
182
304
|
render: () => {
|
|
183
|
-
const [status, setStatus] = useState<string>('
|
|
305
|
+
const [status, setStatus] = useState<string>('');
|
|
184
306
|
|
|
185
|
-
const
|
|
307
|
+
const simulateAsync = async (action: string) => {
|
|
186
308
|
setStatus(`Processing ${action}...`);
|
|
187
309
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
188
310
|
setStatus(`${action} complete!`);
|
|
189
|
-
setTimeout(() => setStatus('
|
|
311
|
+
setTimeout(() => setStatus(''), 2000);
|
|
190
312
|
};
|
|
191
313
|
|
|
192
314
|
return (
|
|
193
315
|
<Stack gap="md">
|
|
194
|
-
|
|
316
|
+
{status && (
|
|
317
|
+
<div className="p-3 bg-accent-50 border border-accent-200 rounded-lg">
|
|
318
|
+
<Text size="sm" className="text-accent-800">{status}</Text>
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
195
321
|
<SwipeableListItem
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
label: '
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
icon: X,
|
|
205
|
-
color: 'destructive',
|
|
206
|
-
label: 'Reject',
|
|
207
|
-
}}
|
|
322
|
+
rightActions={[
|
|
323
|
+
{ id: 'approve', icon: Check, color: 'success', label: 'Approve', onClick: () => simulateAsync('Approval') },
|
|
324
|
+
{ id: 'flag', icon: Flag, color: 'warning', label: 'Flag', onClick: () => simulateAsync('Flagging') },
|
|
325
|
+
]}
|
|
326
|
+
leftActions={[
|
|
327
|
+
{ id: 'reject', icon: X, color: 'destructive', label: 'Reject', onClick: () => simulateAsync('Rejection') },
|
|
328
|
+
{ id: 'defer', icon: Clock, color: 'neutral', label: 'Defer', onClick: () => simulateAsync('Deferral') },
|
|
329
|
+
]}
|
|
208
330
|
>
|
|
209
331
|
<ListItemContent
|
|
210
|
-
|
|
211
|
-
|
|
332
|
+
avatar="TX"
|
|
333
|
+
title="Transaction #12345"
|
|
334
|
+
subtitle="$2,500.00 - Pending review"
|
|
335
|
+
badge="Pending"
|
|
212
336
|
/>
|
|
213
337
|
</SwipeableListItem>
|
|
214
338
|
</Stack>
|
|
@@ -217,132 +341,72 @@ export const AsyncCallbacks: Story = {
|
|
|
217
341
|
};
|
|
218
342
|
|
|
219
343
|
/**
|
|
220
|
-
*
|
|
344
|
+
* Notifications example with varied actions
|
|
221
345
|
*/
|
|
222
|
-
export const
|
|
223
|
-
render: () => (
|
|
224
|
-
<SwipeableListItem
|
|
225
|
-
onSwipeRight={() => console.log('Approved!')}
|
|
226
|
-
onSwipeLeft={() => console.log('Dismissed!')}
|
|
227
|
-
rightAction={{
|
|
228
|
-
icon: Check,
|
|
229
|
-
color: 'success',
|
|
230
|
-
label: 'Approve',
|
|
231
|
-
}}
|
|
232
|
-
leftAction={{
|
|
233
|
-
icon: X,
|
|
234
|
-
color: 'destructive',
|
|
235
|
-
label: 'Dismiss',
|
|
236
|
-
}}
|
|
237
|
-
disabled
|
|
238
|
-
>
|
|
239
|
-
<ListItemContent
|
|
240
|
-
title="Disabled list item"
|
|
241
|
-
subtitle="Swipe gestures are disabled"
|
|
242
|
-
/>
|
|
243
|
-
</SwipeableListItem>
|
|
244
|
-
),
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Custom colors
|
|
249
|
-
*/
|
|
250
|
-
export const CustomColors: Story = {
|
|
251
|
-
render: () => (
|
|
252
|
-
<Stack gap="none">
|
|
253
|
-
<SwipeableListItem
|
|
254
|
-
onSwipeRight={() => console.log('Starred!')}
|
|
255
|
-
onSwipeLeft={() => console.log('Archived!')}
|
|
256
|
-
rightAction={{
|
|
257
|
-
icon: Star,
|
|
258
|
-
color: 'warning',
|
|
259
|
-
label: 'Star',
|
|
260
|
-
}}
|
|
261
|
-
leftAction={{
|
|
262
|
-
icon: Archive,
|
|
263
|
-
color: 'primary',
|
|
264
|
-
label: 'Archive',
|
|
265
|
-
}}
|
|
266
|
-
>
|
|
267
|
-
<ListItemContent
|
|
268
|
-
title="Custom action colors"
|
|
269
|
-
subtitle="Warning (star) and Primary (archive)"
|
|
270
|
-
/>
|
|
271
|
-
</SwipeableListItem>
|
|
272
|
-
</Stack>
|
|
273
|
-
),
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Email inbox example
|
|
278
|
-
*/
|
|
279
|
-
export const EmailInbox: Story = {
|
|
346
|
+
export const Notifications: Story = {
|
|
280
347
|
render: () => {
|
|
281
|
-
const [
|
|
282
|
-
{ id: 1,
|
|
283
|
-
{ id: 2,
|
|
284
|
-
{ id: 3,
|
|
348
|
+
const [notifications, setNotifications] = useState([
|
|
349
|
+
{ id: 1, title: 'New comment on your post', subtitle: 'Sarah replied to your question', time: '2m ago', pinned: false },
|
|
350
|
+
{ id: 2, title: 'Meeting reminder', subtitle: 'Team standup in 15 minutes', time: '15m ago', pinned: true },
|
|
351
|
+
{ id: 3, title: 'Weekly report ready', subtitle: 'Your analytics report is available', time: '1h ago', pinned: false },
|
|
285
352
|
]);
|
|
286
353
|
|
|
287
354
|
return (
|
|
288
|
-
<Stack gap="none" className="border border-paper-200 rounded-lg overflow-hidden">
|
|
289
|
-
|
|
355
|
+
<Stack gap="none" className="border border-paper-200 rounded-lg overflow-hidden shadow-sm">
|
|
356
|
+
<div className="bg-paper-100 px-4 py-2 border-b border-paper-200 flex justify-between items-center">
|
|
357
|
+
<Text size="sm" weight="medium" className="text-ink-600">Notifications</Text>
|
|
358
|
+
<Badge variant="primary" size="sm">{notifications.length}</Badge>
|
|
359
|
+
</div>
|
|
360
|
+
{notifications.map((notif) => (
|
|
290
361
|
<SwipeableListItem
|
|
291
|
-
key={
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
362
|
+
key={notif.id}
|
|
363
|
+
rightActions={[
|
|
364
|
+
{
|
|
365
|
+
id: 'pin',
|
|
366
|
+
icon: Pin,
|
|
367
|
+
color: notif.pinned ? 'warning' : 'neutral',
|
|
368
|
+
label: notif.pinned ? 'Unpin' : 'Pin',
|
|
369
|
+
onClick: () => setNotifications(notifications.map(n =>
|
|
370
|
+
n.id === notif.id ? { ...n, pinned: !n.pinned } : n
|
|
371
|
+
))
|
|
372
|
+
},
|
|
373
|
+
]}
|
|
374
|
+
leftActions={[
|
|
375
|
+
{
|
|
376
|
+
id: 'dismiss',
|
|
377
|
+
icon: X,
|
|
378
|
+
color: 'destructive',
|
|
379
|
+
label: 'Dismiss',
|
|
380
|
+
onClick: () => setNotifications(notifications.filter(n => n.id !== notif.id))
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
id: 'mute',
|
|
384
|
+
icon: BellOff,
|
|
385
|
+
color: 'neutral',
|
|
386
|
+
label: 'Mute',
|
|
387
|
+
onClick: () => console.log('Muted')
|
|
388
|
+
},
|
|
389
|
+
]}
|
|
312
390
|
>
|
|
313
|
-
<div className="p-4 border-b border-paper-100">
|
|
314
|
-
<Stack direction="horizontal"
|
|
391
|
+
<div className="p-4 border-b border-paper-100 bg-white">
|
|
392
|
+
<Stack direction="horizontal" gap="md" align="start">
|
|
393
|
+
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center">
|
|
394
|
+
<Bell className="h-5 w-5 text-white" />
|
|
395
|
+
</div>
|
|
315
396
|
<Stack gap="xs" className="flex-1 min-w-0">
|
|
316
|
-
<Stack direction="horizontal"
|
|
317
|
-
|
|
318
|
-
<
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
className="truncate"
|
|
323
|
-
>
|
|
324
|
-
{email.from}
|
|
325
|
-
</Text>
|
|
397
|
+
<Stack direction="horizontal" justify="between" align="center">
|
|
398
|
+
<Stack direction="horizontal" gap="sm" align="center">
|
|
399
|
+
{notif.pinned && <Pin className="h-3 w-3 text-warning-500" />}
|
|
400
|
+
<Text weight="medium" size="sm">{notif.title}</Text>
|
|
401
|
+
</Stack>
|
|
402
|
+
<Text size="xs" className="text-ink-400">{notif.time}</Text>
|
|
326
403
|
</Stack>
|
|
327
|
-
<Text
|
|
328
|
-
size="sm"
|
|
329
|
-
className={email.unread ? 'text-ink-700' : 'text-ink-400'}
|
|
330
|
-
>
|
|
331
|
-
{email.subject}
|
|
332
|
-
</Text>
|
|
404
|
+
<Text size="sm" className="text-ink-500">{notif.subtitle}</Text>
|
|
333
405
|
</Stack>
|
|
334
|
-
<Text size="xs" className="text-ink-400 flex-shrink-0 ml-2">
|
|
335
|
-
{email.time}
|
|
336
|
-
</Text>
|
|
337
406
|
</Stack>
|
|
338
407
|
</div>
|
|
339
408
|
</SwipeableListItem>
|
|
340
409
|
))}
|
|
341
|
-
{emails.length === 0 && (
|
|
342
|
-
<div className="p-8 text-center text-ink-400">
|
|
343
|
-
Inbox empty!
|
|
344
|
-
</div>
|
|
345
|
-
)}
|
|
346
410
|
</Stack>
|
|
347
411
|
);
|
|
348
412
|
},
|
|
@@ -355,32 +419,44 @@ export const KeyboardNavigation: Story = {
|
|
|
355
419
|
render: () => (
|
|
356
420
|
<Stack gap="md">
|
|
357
421
|
<div className="p-4 bg-paper-100 rounded-lg">
|
|
358
|
-
<Text size="sm" weight="medium">Keyboard
|
|
359
|
-
<
|
|
360
|
-
<
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
<
|
|
365
|
-
|
|
422
|
+
<Text size="sm" weight="medium" className="mb-3">Keyboard Controls:</Text>
|
|
423
|
+
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
424
|
+
<div className="flex items-center gap-2">
|
|
425
|
+
<kbd className="px-2 py-1 bg-paper-200 rounded text-xs font-mono">→</kbd>
|
|
426
|
+
<span className="text-ink-600">Open right actions</span>
|
|
427
|
+
</div>
|
|
428
|
+
<div className="flex items-center gap-2">
|
|
429
|
+
<kbd className="px-2 py-1 bg-paper-200 rounded text-xs font-mono">←</kbd>
|
|
430
|
+
<span className="text-ink-600">Open left actions</span>
|
|
431
|
+
</div>
|
|
432
|
+
<div className="flex items-center gap-2">
|
|
433
|
+
<kbd className="px-2 py-1 bg-paper-200 rounded text-xs font-mono">Tab</kbd>
|
|
434
|
+
<span className="text-ink-600">Navigate actions</span>
|
|
435
|
+
</div>
|
|
436
|
+
<div className="flex items-center gap-2">
|
|
437
|
+
<kbd className="px-2 py-1 bg-paper-200 rounded text-xs font-mono">Enter</kbd>
|
|
438
|
+
<span className="text-ink-600">Execute action</span>
|
|
439
|
+
</div>
|
|
440
|
+
<div className="flex items-center gap-2">
|
|
441
|
+
<kbd className="px-2 py-1 bg-paper-200 rounded text-xs font-mono">Esc</kbd>
|
|
442
|
+
<span className="text-ink-600">Close / Reset</span>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
366
445
|
</div>
|
|
367
446
|
<SwipeableListItem
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
label: '
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
icon: X,
|
|
377
|
-
color: 'destructive',
|
|
378
|
-
label: 'Dismiss',
|
|
379
|
-
}}
|
|
447
|
+
rightActions={[
|
|
448
|
+
{ id: 'edit', icon: Edit, color: 'primary', label: 'Edit', onClick: () => alert('Edit!') },
|
|
449
|
+
{ id: 'star', icon: Star, color: 'warning', label: 'Star', onClick: () => alert('Star!') },
|
|
450
|
+
]}
|
|
451
|
+
leftActions={[
|
|
452
|
+
{ id: 'delete', icon: Trash, color: 'destructive', label: 'Delete', onClick: () => alert('Delete!') },
|
|
453
|
+
{ id: 'archive', icon: Archive, color: 'neutral', label: 'Archive', onClick: () => alert('Archive!') },
|
|
454
|
+
]}
|
|
380
455
|
>
|
|
381
456
|
<ListItemContent
|
|
382
|
-
|
|
383
|
-
|
|
457
|
+
avatar="KB"
|
|
458
|
+
title="Focus me and use keyboard"
|
|
459
|
+
subtitle="Press arrow keys to reveal actions, Tab to navigate"
|
|
384
460
|
/>
|
|
385
461
|
</SwipeableListItem>
|
|
386
462
|
</Stack>
|
|
@@ -388,55 +464,51 @@ export const KeyboardNavigation: Story = {
|
|
|
388
464
|
};
|
|
389
465
|
|
|
390
466
|
/**
|
|
391
|
-
*
|
|
467
|
+
* Disabled state
|
|
392
468
|
*/
|
|
393
|
-
export const
|
|
394
|
-
render: () =>
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
{
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
{
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
469
|
+
export const Disabled: Story = {
|
|
470
|
+
render: () => (
|
|
471
|
+
<SwipeableListItem
|
|
472
|
+
disabled
|
|
473
|
+
rightActions={[
|
|
474
|
+
{ id: 'approve', icon: Check, color: 'success', label: 'Approve', onClick: () => {} }
|
|
475
|
+
]}
|
|
476
|
+
leftActions={[
|
|
477
|
+
{ id: 'dismiss', icon: X, color: 'destructive', label: 'Dismiss', onClick: () => {} }
|
|
478
|
+
]}
|
|
479
|
+
>
|
|
480
|
+
<ListItemContent
|
|
481
|
+
avatar="DI"
|
|
482
|
+
title="Disabled list item"
|
|
483
|
+
subtitle="Swipe gestures are disabled"
|
|
484
|
+
/>
|
|
485
|
+
</SwipeableListItem>
|
|
486
|
+
),
|
|
487
|
+
};
|
|
412
488
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
))}
|
|
439
|
-
</Stack>
|
|
440
|
-
);
|
|
441
|
-
},
|
|
489
|
+
/**
|
|
490
|
+
* Custom action width
|
|
491
|
+
*/
|
|
492
|
+
export const CustomActionWidth: Story = {
|
|
493
|
+
render: () => (
|
|
494
|
+
<Stack gap="md">
|
|
495
|
+
<Text size="sm" className="text-ink-500">Wider action buttons (96px each)</Text>
|
|
496
|
+
<SwipeableListItem
|
|
497
|
+
actionWidth={96}
|
|
498
|
+
rightActions={[
|
|
499
|
+
{ id: 'approve', icon: Check, color: 'success', label: 'Approve', onClick: () => {} },
|
|
500
|
+
]}
|
|
501
|
+
leftActions={[
|
|
502
|
+
{ id: 'delete', icon: Trash, color: 'destructive', label: 'Delete', onClick: () => {} },
|
|
503
|
+
{ id: 'archive', icon: Archive, color: 'warning', label: 'Archive', onClick: () => {} },
|
|
504
|
+
]}
|
|
505
|
+
>
|
|
506
|
+
<ListItemContent
|
|
507
|
+
avatar="CW"
|
|
508
|
+
title="Wider action buttons"
|
|
509
|
+
subtitle="actionWidth={96}"
|
|
510
|
+
/>
|
|
511
|
+
</SwipeableListItem>
|
|
512
|
+
</Stack>
|
|
513
|
+
),
|
|
442
514
|
};
|