@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.
@@ -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 functionality for mobile touch gestures.
17
+ A list item component with swipe-to-reveal action buttons for mobile touch gestures.
18
18
 
19
19
  ## Features
20
- - **Touch gestures**: Swipe left or right to reveal actions
21
- - **Mouse support**: Works with mouse drag for desktop testing
22
- - **Keyboard accessible**: Arrow keys to preview, Enter to confirm, Escape to cancel
23
- - **Async support**: Handles async callbacks with loading state
24
- - **Haptic feedback**: Vibration on mobile devices
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
- ## Accessibility
27
- - Use Arrow Left/Right to simulate swipe direction
28
- - Press Enter to confirm the action when highlighted
29
- - Press Escape to cancel and reset
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, action)
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" justify="between" align="center">
74
- <Stack gap="xs">
75
- <Text weight="medium">{title}</Text>
76
- <Text size="sm" className="text-ink-400">{subtitle}</Text>
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 example with both swipe directions
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
- onSwipeRight={() => handleApprove(item.id)}
110
- onSwipeLeft={() => handleDismiss(item.id)}
111
- rightAction={{
112
- icon: Check,
113
- color: 'success',
114
- label: 'Approve',
115
- }}
116
- leftAction={{
117
- icon: X,
118
- color: 'destructive',
119
- label: 'Dismiss',
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
- * Right swipe only (approve action)
140
+ * Multiple actions per side (email-style)
137
141
  */
138
- export const RightSwipeOnly: Story = {
139
- render: () => (
140
- <SwipeableListItem
141
- onSwipeRight={() => console.log('Approved!')}
142
- rightAction={{
143
- icon: Check,
144
- color: 'success',
145
- label: 'Approve',
146
- }}
147
- >
148
- <ListItemContent
149
- title="Swipe right to approve"
150
- subtitle="Only right swipe is enabled"
151
- badge="Pending"
152
- />
153
- </SwipeableListItem>
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
- * Left swipe only (delete action)
236
+ * Full swipe to trigger action
159
237
  */
160
- export const LeftSwipeOnly: Story = {
161
- render: () => (
162
- <SwipeableListItem
163
- onSwipeLeft={() => console.log('Deleted!')}
164
- leftAction={{
165
- icon: Trash,
166
- color: 'destructive',
167
- label: 'Delete',
168
- }}
169
- >
170
- <ListItemContent
171
- title="Swipe left to delete"
172
- subtitle="Only left swipe is enabled"
173
- />
174
- </SwipeableListItem>
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 callbacks showing loading state
301
+ * With async loading states
180
302
  */
181
- export const AsyncCallbacks: Story = {
303
+ export const AsyncActions: Story = {
182
304
  render: () => {
183
- const [status, setStatus] = useState<string>('Ready');
305
+ const [status, setStatus] = useState<string>('');
184
306
 
185
- const handleAsyncAction = async (action: string) => {
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('Ready'), 2000);
311
+ setTimeout(() => setStatus(''), 2000);
190
312
  };
191
313
 
192
314
  return (
193
315
  <Stack gap="md">
194
- <Text size="sm" className="text-ink-500">Status: {status}</Text>
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
- onSwipeRight={() => handleAsyncAction('approval')}
197
- onSwipeLeft={() => handleAsyncAction('rejection')}
198
- rightAction={{
199
- icon: Check,
200
- color: 'success',
201
- label: 'Approve',
202
- }}
203
- leftAction={{
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
- title="Async action demo"
211
- subtitle="Watch for loading spinner during action"
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
- * Disabled state
344
+ * Notifications example with varied actions
221
345
  */
222
- export const Disabled: Story = {
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 [emails, setEmails] = useState([
282
- { id: 1, from: 'boss@company.com', subject: 'Q4 Budget Review', time: '10:30 AM', unread: true },
283
- { id: 2, from: 'team@company.com', subject: 'Sprint Planning Notes', time: '9:15 AM', unread: true },
284
- { id: 3, from: 'hr@company.com', subject: 'Holiday Schedule Update', time: 'Yesterday', unread: false },
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
- {emails.map((email) => (
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={email.id}
292
- onSwipeRight={() => {
293
- console.log('Marked as read:', email.id);
294
- setEmails(emails.map(e =>
295
- e.id === email.id ? { ...e, unread: false } : e
296
- ));
297
- }}
298
- onSwipeLeft={() => {
299
- console.log('Deleted:', email.id);
300
- setEmails(emails.filter(e => e.id !== email.id));
301
- }}
302
- rightAction={{
303
- icon: email.unread ? MailOpen : Mail,
304
- color: 'primary',
305
- label: email.unread ? 'Mark as read' : 'Mark as unread',
306
- }}
307
- leftAction={{
308
- icon: Trash,
309
- color: 'destructive',
310
- label: 'Delete',
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" justify="between" align="start">
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" gap="sm" align="center">
317
- {email.unread && (
318
- <div className="w-2 h-2 rounded-full bg-accent-500 flex-shrink-0" />
319
- )}
320
- <Text
321
- weight={email.unread ? 'semibold' : 'normal'}
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 Instructions:</Text>
359
- <ul className="mt-2 text-sm text-ink-600 space-y-1">
360
- <li>• <kbd className="px-1 bg-paper-200 rounded">Tab</kbd> to focus the item</li>
361
- <li>• <kbd className="px-1 bg-paper-200 rounded">→</kbd> to preview right action</li>
362
- <li>• <kbd className="px-1 bg-paper-200 rounded">←</kbd> to preview left action</li>
363
- <li>• <kbd className="px-1 bg-paper-200 rounded">Enter</kbd> to confirm</li>
364
- <li>• <kbd className="px-1 bg-paper-200 rounded">Esc</kbd> to cancel</li>
365
- </ul>
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
- onSwipeRight={() => alert('Approved via keyboard!')}
369
- onSwipeLeft={() => alert('Dismissed via keyboard!')}
370
- rightAction={{
371
- icon: Check,
372
- color: 'success',
373
- label: 'Approve',
374
- }}
375
- leftAction={{
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
- title="Focus me and use arrow keys"
383
- subtitle="Then press Enter to confirm or Escape to cancel"
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
- * Multiple items in a list
467
+ * Disabled state
392
468
  */
393
- export const MultipleItems: Story = {
394
- render: () => {
395
- const [todos, setTodos] = useState([
396
- { id: 1, text: 'Review pull request', done: false },
397
- { id: 2, text: 'Update documentation', done: false },
398
- { id: 3, text: 'Fix CI pipeline', done: false },
399
- { id: 4, text: 'Deploy to staging', done: false },
400
- { id: 5, text: 'Write unit tests', done: false },
401
- ]);
402
-
403
- const handleComplete = (id: number) => {
404
- setTodos(todos.map(t =>
405
- t.id === id ? { ...t, done: true } : t
406
- ));
407
- };
408
-
409
- const handleDelete = (id: number) => {
410
- setTodos(todos.filter(t => t.id !== id));
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
- return (
414
- <Stack gap="none" className="border border-paper-200 rounded-lg overflow-hidden">
415
- {todos.map((todo) => (
416
- <SwipeableListItem
417
- key={todo.id}
418
- onSwipeRight={() => handleComplete(todo.id)}
419
- onSwipeLeft={() => handleDelete(todo.id)}
420
- rightAction={{
421
- icon: Check,
422
- color: 'success',
423
- label: 'Complete',
424
- }}
425
- leftAction={{
426
- icon: Trash,
427
- color: 'destructive',
428
- label: 'Delete',
429
- }}
430
- disabled={todo.done}
431
- >
432
- <div className={`p-4 border-b border-paper-100 ${todo.done ? 'bg-paper-50' : ''}`}>
433
- <Text className={todo.done ? 'line-through text-ink-400' : ''}>
434
- {todo.text}
435
- </Text>
436
- </div>
437
- </SwipeableListItem>
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
  };