even-toolkit 1.4.2 → 1.5.1

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 (75) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +7 -5
  3. package/dist/glasses/action-bar.d.ts +3 -3
  4. package/dist/glasses/action-bar.js +7 -7
  5. package/dist/glasses/action-bar.js.map +1 -1
  6. package/dist/glasses/action-map.js +3 -3
  7. package/dist/glasses/action-map.js.map +1 -1
  8. package/dist/glasses/gestures.d.ts +4 -0
  9. package/dist/glasses/gestures.d.ts.map +1 -1
  10. package/dist/glasses/gestures.js +44 -0
  11. package/dist/glasses/gestures.js.map +1 -1
  12. package/dist/glasses/glass-chat-display.d.ts +43 -0
  13. package/dist/glasses/glass-chat-display.d.ts.map +1 -0
  14. package/dist/glasses/glass-chat-display.js +102 -0
  15. package/dist/glasses/glass-chat-display.js.map +1 -0
  16. package/dist/glasses/glass-format.d.ts +50 -0
  17. package/dist/glasses/glass-format.d.ts.map +1 -0
  18. package/dist/glasses/glass-format.js +65 -0
  19. package/dist/glasses/glass-format.js.map +1 -0
  20. package/dist/glasses/index.d.ts +2 -0
  21. package/dist/glasses/index.d.ts.map +1 -1
  22. package/dist/glasses/index.js +2 -0
  23. package/dist/glasses/index.js.map +1 -1
  24. package/dist/glasses/useGlasses.js +1 -1
  25. package/dist/glasses/useGlasses.js.map +1 -1
  26. package/dist/stt/providers/deepgram.d.ts +1 -0
  27. package/dist/stt/providers/deepgram.d.ts.map +1 -1
  28. package/dist/stt/providers/deepgram.js +25 -8
  29. package/dist/stt/providers/deepgram.js.map +1 -1
  30. package/dist/web/components/dialog.d.ts.map +1 -1
  31. package/dist/web/components/dialog.js +13 -1
  32. package/dist/web/components/dialog.js.map +1 -1
  33. package/dist/web/components/drawer-shell.d.ts.map +1 -1
  34. package/dist/web/components/drawer-shell.js +11 -1
  35. package/dist/web/components/drawer-shell.js.map +1 -1
  36. package/dist/web/components/list-item.d.ts +1 -1
  37. package/dist/web/components/list-item.d.ts.map +1 -1
  38. package/dist/web/components/list-item.js +20 -5
  39. package/dist/web/components/list-item.js.map +1 -1
  40. package/dist/web/components/multi-select.d.ts +22 -0
  41. package/dist/web/components/multi-select.d.ts.map +1 -0
  42. package/dist/web/components/multi-select.js +52 -0
  43. package/dist/web/components/multi-select.js.map +1 -0
  44. package/dist/web/components/paged-carousel.d.ts +20 -0
  45. package/dist/web/components/paged-carousel.d.ts.map +1 -0
  46. package/dist/web/components/paged-carousel.js +140 -0
  47. package/dist/web/components/paged-carousel.js.map +1 -0
  48. package/dist/web/components/select.d.ts +13 -3
  49. package/dist/web/components/select.d.ts.map +1 -1
  50. package/dist/web/components/select.js +36 -3
  51. package/dist/web/components/select.js.map +1 -1
  52. package/dist/web/icons/svg-icons.js +1 -1
  53. package/dist/web/icons/svg-icons.js.map +1 -1
  54. package/dist/web/index.d.ts +4 -0
  55. package/dist/web/index.d.ts.map +1 -1
  56. package/dist/web/index.js +2 -0
  57. package/dist/web/index.js.map +1 -1
  58. package/glasses/action-bar.ts +7 -7
  59. package/glasses/action-map.ts +3 -3
  60. package/glasses/gestures.ts +50 -0
  61. package/glasses/glass-chat-display.ts +152 -0
  62. package/glasses/glass-format.ts +75 -0
  63. package/glasses/index.ts +2 -0
  64. package/glasses/useGlasses.ts +1 -1
  65. package/package.json +10 -1
  66. package/stt/providers/deepgram.ts +23 -7
  67. package/web/components/dialog.tsx +14 -3
  68. package/web/components/drawer-shell.tsx +11 -5
  69. package/web/components/list-item.tsx +25 -10
  70. package/web/components/multi-select.tsx +118 -0
  71. package/web/components/paged-carousel.tsx +201 -0
  72. package/web/components/select.tsx +90 -20
  73. package/web/icons/svg-icons.tsx +1 -2
  74. package/web/index.ts +6 -0
  75. package/web/theme/utilities.css +11 -0
@@ -10,6 +10,14 @@ const SCROLL_SUPPRESS_AFTER_TEXT_MS = 80;
10
10
 
11
11
  let lastTapTime = 0;
12
12
  let lastTapKind: 'tap' | 'double' | null = null;
13
+ let bypassNextScrollChecks = false;
14
+
15
+ function consumeExternalScrollBypass(): boolean {
16
+ const g = globalThis as typeof globalThis & { __evenAllowImmediateScrollOnce?: boolean };
17
+ if (!g.__evenAllowImmediateScrollOnce) return false;
18
+ g.__evenAllowImmediateScrollOnce = false;
19
+ return true;
20
+ }
13
21
 
14
22
  export function tryConsumeTap(kind: 'tap' | 'double'): boolean {
15
23
  const now = Date.now();
@@ -31,6 +39,10 @@ export function tryConsumeTap(kind: 'tap' | 'double'): boolean {
31
39
  }
32
40
 
33
41
  export function isScrollSuppressed(): boolean {
42
+ if (bypassNextScrollChecks || consumeExternalScrollBypass()) {
43
+ bypassNextScrollChecks = false;
44
+ return false;
45
+ }
34
46
  return Date.now() - lastTapTime < SCROLL_SUPPRESS_AFTER_TAP_MS;
35
47
  }
36
48
 
@@ -46,6 +58,12 @@ export function notifyTextUpdate(): void {
46
58
  export function isScrollDebounced(direction: 'prev' | 'next'): boolean {
47
59
  const now = Date.now();
48
60
 
61
+ if (bypassNextScrollChecks || consumeExternalScrollBypass()) {
62
+ lastScrollTime = now;
63
+ lastScrollDir = direction;
64
+ return false;
65
+ }
66
+
49
67
  // Suppress scrolls briefly after a text update (G2 re-layout fires spurious events)
50
68
  if (now - textUpdateTime < SCROLL_SUPPRESS_AFTER_TEXT_MS) return true;
51
69
 
@@ -58,3 +76,35 @@ export function isScrollDebounced(direction: 'prev' | 'next'): boolean {
58
76
  lastScrollDir = direction;
59
77
  return false;
60
78
  }
79
+
80
+ /** Shared scroll gate so a single bypass applies to the full event. */
81
+ export function shouldIgnoreScroll(direction: 'prev' | 'next'): boolean {
82
+ const now = Date.now();
83
+ const bypassed = bypassNextScrollChecks || consumeExternalScrollBypass();
84
+
85
+ if (bypassed) {
86
+ bypassNextScrollChecks = false;
87
+ lastScrollTime = now;
88
+ lastScrollDir = direction;
89
+ return false;
90
+ }
91
+
92
+ if (now - lastTapTime < SCROLL_SUPPRESS_AFTER_TAP_MS) return true;
93
+ if (now - textUpdateTime < SCROLL_SUPPRESS_AFTER_TEXT_MS) return true;
94
+
95
+ const threshold =
96
+ direction === lastScrollDir ? SAME_DIRECTION_DEBOUNCE_MS : DIRECTION_CHANGE_DEBOUNCE_MS;
97
+
98
+ if (now - lastScrollTime < threshold) return true;
99
+
100
+ lastScrollTime = now;
101
+ lastScrollDir = direction;
102
+ return false;
103
+ }
104
+
105
+ /** Allow the next scroll event through immediately after a tap-driven mode change. */
106
+ export function armImmediateScroll(): void {
107
+ bypassNextScrollChecks = true;
108
+ lastScrollTime = 0;
109
+ lastScrollDir = null;
110
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Chat/terminal output display builder for G2 glasses.
3
+ * Optimized for reading streaming AI output on a 10-line text display.
4
+ *
5
+ * Line type prefixes:
6
+ * > user prompt
7
+ * >> tool call
8
+ * + collapsed thinking
9
+ * - expanded thinking header
10
+ * ! error
11
+ * (no prefix) assistant text
12
+ */
13
+
14
+ import type { DisplayData, DisplayLine } from './types';
15
+ import { line, glassHeader } from './types';
16
+ import { applyScrollIndicators } from './text-utils';
17
+
18
+ export interface ChatLine {
19
+ type: 'prompt' | 'text' | 'tool' | 'thinking-collapsed' | 'thinking-expanded' | 'thinking-body' | 'error' | 'system';
20
+ text: string;
21
+ }
22
+
23
+ /**
24
+ * Format a single ChatLine into one or more display strings,
25
+ * word-wrapping at maxChars.
26
+ */
27
+ export function formatChatLine(chatLine: ChatLine, maxChars = 44): string[] {
28
+ const { type, text } = chatLine;
29
+
30
+ let prefix: string;
31
+ switch (type) {
32
+ case 'prompt':
33
+ prefix = '> ';
34
+ break;
35
+ case 'tool':
36
+ prefix = '>> ';
37
+ break;
38
+ case 'thinking-collapsed':
39
+ prefix = '+ ';
40
+ break;
41
+ case 'thinking-expanded':
42
+ prefix = '- ';
43
+ break;
44
+ case 'thinking-body':
45
+ prefix = ' ';
46
+ break;
47
+ case 'error':
48
+ prefix = '! ';
49
+ break;
50
+ case 'system':
51
+ prefix = '= ';
52
+ break;
53
+ case 'text':
54
+ default:
55
+ prefix = '';
56
+ break;
57
+ }
58
+
59
+ const available = maxChars - prefix.length;
60
+ if (text.length <= available) {
61
+ return [`${prefix}${text}`];
62
+ }
63
+
64
+ // Word-wrap: try to break at word boundaries
65
+ const lines: string[] = [];
66
+ let remaining = text;
67
+ let isFirst = true;
68
+
69
+ while (remaining.length > 0) {
70
+ const pfx = isFirst ? prefix : ' '.repeat(prefix.length);
71
+ const avail = maxChars - pfx.length;
72
+
73
+ if (remaining.length <= avail) {
74
+ lines.push(`${pfx}${remaining}`);
75
+ break;
76
+ }
77
+
78
+ // Find last space within available width
79
+ let breakAt = remaining.lastIndexOf(' ', avail);
80
+ if (breakAt <= 0) breakAt = avail; // no space found, hard break
81
+
82
+ lines.push(`${pfx}${remaining.slice(0, breakAt)}`);
83
+ remaining = remaining.slice(breakAt).trimStart();
84
+ isFirst = false;
85
+ }
86
+
87
+ return lines;
88
+ }
89
+
90
+ export interface ChatDisplayOptions {
91
+ /** Header title, e.g. "CLAUDE · opus · running" */
92
+ title: string;
93
+ /** Action bar string from buildStaticActionBar() */
94
+ actionBar: string;
95
+ /** Ordered chat lines to display */
96
+ chatLines: ChatLine[];
97
+ /** Scroll offset from bottom. 0 = show latest. Positive = scrolled up. */
98
+ scrollOffset: number;
99
+ /** Number of visible content lines. Default: 7 (10 total - 3 header) */
100
+ contentSlots?: number;
101
+ /** Max chars per line. Default: 44 */
102
+ maxChars?: number;
103
+ }
104
+
105
+ /**
106
+ * Build a complete chat display for G2 glasses.
107
+ * Auto-scrolls to bottom (latest content) unless scrollOffset > 0.
108
+ * Returns DisplayData with header + scrollable content.
109
+ */
110
+ export function buildChatDisplay(opts: ChatDisplayOptions): DisplayData {
111
+ const {
112
+ title,
113
+ actionBar,
114
+ chatLines,
115
+ scrollOffset,
116
+ contentSlots = 7,
117
+ maxChars = 44,
118
+ } = opts;
119
+
120
+ const lines = [...glassHeader(title, actionBar)];
121
+
122
+ // Format all chat lines into display strings
123
+ const allLines: string[] = [];
124
+ for (const cl of chatLines) {
125
+ allLines.push(...formatChatLine(cl, maxChars));
126
+ }
127
+
128
+ if (allLines.length === 0) {
129
+ lines.push(line(' Waiting for output...', 'meta'));
130
+ return { lines };
131
+ }
132
+
133
+ // Scroll from bottom: offset 0 = show last contentSlots lines
134
+ const totalLines = allLines.length;
135
+ const maxFromBottom = Math.max(0, totalLines - contentSlots);
136
+ const clampedOffset = Math.min(scrollOffset, maxFromBottom);
137
+ const start = Math.max(0, totalLines - contentSlots - clampedOffset);
138
+
139
+ const visible = allLines.slice(start, start + contentSlots);
140
+ const contentDisplayLines: DisplayLine[] = visible.map((t) => line(t, 'normal'));
141
+
142
+ applyScrollIndicators(
143
+ contentDisplayLines,
144
+ start,
145
+ totalLines,
146
+ contentSlots,
147
+ (t) => line(t, 'meta'),
148
+ );
149
+
150
+ lines.push(...contentDisplayLines);
151
+ return { lines };
152
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Glass display formatting constants and helpers.
3
+ * Safe Unicode characters confirmed working on G2 LVGL display.
4
+ * Inspired by tesla-even-g2 formatting patterns.
5
+ */
6
+
7
+ /** Middle dot · field separator */
8
+ export const SEP = '\u00B7';
9
+
10
+ /** Single right-pointing angle › drill-in indicator */
11
+ export const DRILL = '\u203A';
12
+
13
+ /** Single left-pointing angle ‹ back prefix */
14
+ export const BACK_CHAR = '\u2039';
15
+
16
+ /** En-dash – result separator */
17
+ export const DASH = '\u2013';
18
+
19
+ /** Heavy horizontal ═ filled progress bar segment */
20
+ export const BAR_FILL = '\u2501';
21
+
22
+ /** Light horizontal ─ empty progress bar segment */
23
+ export const BAR_EMPTY = '\u2500';
24
+
25
+ /**
26
+ * Join non-empty values with · separator.
27
+ * Falsy values are filtered out.
28
+ *
29
+ * @example fieldJoin('CLAUDE', 'opus', 'running') → "CLAUDE · opus · running"
30
+ * @example fieldJoin('HOSTS', false, '2 total') → "HOSTS · 2 total"
31
+ */
32
+ export function fieldJoin(...parts: (string | undefined | null | false)[]): string {
33
+ return parts.filter(Boolean).join(` ${SEP} `);
34
+ }
35
+
36
+ /**
37
+ * Render an ASCII progress bar.
38
+ * Uses ═ for filled and ─ for empty.
39
+ *
40
+ * @example progressBar(67) → "═══════───"
41
+ * @example progressBar(30, 20) → "══════──────────────"
42
+ */
43
+ export function progressBar(percent: number, width = 10): string {
44
+ const clamped = Math.max(0, Math.min(100, percent));
45
+ const filled = Math.round((clamped / 100) * width);
46
+ return BAR_FILL.repeat(filled) + BAR_EMPTY.repeat(width - filled);
47
+ }
48
+
49
+ /**
50
+ * Format a key · value pair, truncating value if needed.
51
+ *
52
+ * @example kvLine('Language', 'EN') → "Language · EN"
53
+ */
54
+ export function kvLine(label: string, value: string, maxWidth = 44): string {
55
+ const sep = ` ${SEP} `;
56
+ const available = maxWidth - label.length - sep.length;
57
+ const val = value.length > available ? value.slice(0, available - 1) + '~' : value;
58
+ return `${label}${sep}${val}`;
59
+ }
60
+
61
+ /**
62
+ * Append › drill-in indicator to a label.
63
+ *
64
+ * @example drillLabel('Sessions') → "Sessions ›"
65
+ */
66
+ export function drillLabel(text: string): string {
67
+ return `${text} ${DRILL}`;
68
+ }
69
+
70
+ /**
71
+ * Build a "‹ Back" label for menu items.
72
+ */
73
+ export function backLabel(): string {
74
+ return `${BACK_CHAR} Back`;
75
+ }
package/glasses/index.ts CHANGED
@@ -13,3 +13,5 @@ export * from './glass-display-builders';
13
13
  export * from './glass-mode';
14
14
  export * from './glass-router';
15
15
  export * from './glass-screen-router';
16
+ export * from './glass-format';
17
+ export * from './glass-chat-display';
@@ -71,7 +71,7 @@ export function useGlasses<S>(config: UseGlassesConfig<S>): void {
71
71
  // Build display text from lines
72
72
  const data = configRef.current.toDisplayData(snapshot, nav);
73
73
  const text = data.lines.map(l => {
74
- if (l.style === 'separator') return '\u2500'.repeat(28) + '\n';
74
+ if (l.style === 'separator') return '\u2500'.repeat(28);
75
75
  if (l.inverted) return `\u25B6 ${l.text}`;
76
76
  return ` ${l.text}`;
77
77
  }).join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "even-toolkit",
3
- "version": "1.4.2",
3
+ "version": "1.5.1",
4
4
  "description": "Design system & component library for Even Realities G2 smart glasses apps — 55+ web components, 191 pixel-art icons, glasses SDK bridge, and design tokens.",
5
5
  "type": "module",
6
6
  "main": "./dist/glasses/index.js",
@@ -102,6 +102,14 @@
102
102
  "types": "./dist/glasses/glass-screen-router.d.ts",
103
103
  "import": "./dist/glasses/glass-screen-router.js"
104
104
  },
105
+ "./glass-format": {
106
+ "types": "./dist/glasses/glass-format.d.ts",
107
+ "import": "./dist/glasses/glass-format.js"
108
+ },
109
+ "./glass-chat-display": {
110
+ "types": "./dist/glasses/glass-chat-display.d.ts",
111
+ "import": "./dist/glasses/glass-chat-display.js"
112
+ },
105
113
  "./web": {
106
114
  "types": "./dist/web/index.d.ts",
107
115
  "import": "./dist/web/index.js"
@@ -353,6 +361,7 @@
353
361
  "glasses",
354
362
  "stt",
355
363
  "README.md",
364
+ "CHANGELOG.md",
356
365
  "LICENSE"
357
366
  ],
358
367
  "scripts": {
@@ -28,6 +28,7 @@ export class DeepgramProvider implements STTProvider {
28
28
  private language = 'en';
29
29
  private modelId = 'nova-2';
30
30
  private ws: WebSocket | null = null;
31
+ private suppressSocketEvents = false;
31
32
 
32
33
  private transcriptCbs: Array<(t: STTTranscript) => void> = [];
33
34
  private stateCbs: Array<(s: STTState) => void> = [];
@@ -51,8 +52,9 @@ export class DeepgramProvider implements STTProvider {
51
52
 
52
53
  start(): void {
53
54
  if (this.ws) {
54
- this.closeSocket();
55
+ this.closeSocket(true);
55
56
  }
57
+ this.suppressSocketEvents = false;
56
58
 
57
59
  const params = new URLSearchParams({
58
60
  model: this.modelId,
@@ -98,6 +100,7 @@ export class DeepgramProvider implements STTProvider {
98
100
  };
99
101
 
100
102
  this.ws.onerror = (event) => {
103
+ if (this.suppressSocketEvents) return;
101
104
  sttLog('deepgram: WebSocket error', event);
102
105
  const err: STTError = {
103
106
  code: 'network',
@@ -110,6 +113,11 @@ export class DeepgramProvider implements STTProvider {
110
113
 
111
114
  this.ws.onclose = () => {
112
115
  this.ws = null;
116
+ if (this.suppressSocketEvents) {
117
+ this.suppressSocketEvents = false;
118
+ this.setState('idle');
119
+ return;
120
+ }
113
121
  if (this._state === 'listening') {
114
122
  this.setState('idle');
115
123
  }
@@ -140,15 +148,15 @@ export class DeepgramProvider implements STTProvider {
140
148
  // Send close message per Deepgram protocol
141
149
  this.ws.send(JSON.stringify({ type: 'CloseStream' }));
142
150
  }
143
- this.closeSocket();
151
+ this.closeSocket(true);
144
152
  }
145
153
 
146
154
  abort(): void {
147
- this.closeSocket();
155
+ this.closeSocket(true);
148
156
  }
149
157
 
150
158
  dispose(): void {
151
- this.closeSocket();
159
+ this.closeSocket(true);
152
160
  this.transcriptCbs = [];
153
161
  this.stateCbs = [];
154
162
  this.errorCbs = [];
@@ -171,10 +179,18 @@ export class DeepgramProvider implements STTProvider {
171
179
 
172
180
  // ── Private ──
173
181
 
174
- private closeSocket(): void {
175
- if (this.ws) {
176
- try { this.ws.close(); } catch { /* ignore */ }
182
+ private closeSocket(silent = false): void {
183
+ const socket = this.ws;
184
+ if (socket) {
177
185
  this.ws = null;
186
+ if (silent) {
187
+ this.suppressSocketEvents = true;
188
+ socket.onopen = null;
189
+ socket.onmessage = null;
190
+ socket.onerror = null;
191
+ socket.onclose = null;
192
+ }
193
+ try { socket.close(); } catch { /* ignore */ }
178
194
  }
179
195
  this.setState('idle');
180
196
  }
@@ -21,7 +21,6 @@ interface DialogProps {
21
21
  function Dialog({ open, onClose, title, icon, children, actions, className }: DialogProps) {
22
22
  const [visible, setVisible] = React.useState(false);
23
23
  const [closing, setClosing] = React.useState(false);
24
-
25
24
  React.useEffect(() => {
26
25
  if (open) {
27
26
  setVisible(true);
@@ -42,17 +41,29 @@ function Dialog({ open, onClose, title, icon, children, actions, className }: Di
42
41
  return () => document.removeEventListener('keydown', handler);
43
42
  }, [visible, onClose]);
44
43
 
44
+ React.useEffect(() => {
45
+ if (!visible) return;
46
+ const prevBodyOverflow = document.body.style.overflow;
47
+ const prevHtmlOverflow = document.documentElement.style.overflow;
48
+ document.body.style.overflow = 'hidden';
49
+ document.documentElement.style.overflow = 'hidden';
50
+ return () => {
51
+ document.body.style.overflow = prevBodyOverflow;
52
+ document.documentElement.style.overflow = prevHtmlOverflow;
53
+ };
54
+ }, [visible]);
55
+
45
56
  if (!visible) return null;
46
57
 
47
58
  return (
48
59
  <div
49
- className="fixed inset-0 z-50 flex items-center justify-center px-6"
60
+ className="fixed inset-0 z-50 flex items-center justify-center px-6 overscroll-contain"
50
61
  style={{ animation: closing ? 'fadeOut 200ms ease forwards' : 'fadeIn 200ms ease' }}
51
62
  >
52
63
  <div className="absolute inset-0 bg-overlay" onClick={onClose} />
53
64
  <div
54
65
  className={cn(
55
- 'relative z-10 w-full max-w-[311px] bg-surface rounded-[6px] shadow-[0_4px_24px_rgba(0,0,0,0.16)] p-4',
66
+ 'relative z-10 w-full max-w-[311px] bg-surface rounded-[6px] shadow-[0_4px_24px_rgba(0,0,0,0.16)] p-4 overscroll-contain',
56
67
  className,
57
68
  )}
58
69
  style={{ animation: closing ? 'fadeOut 200ms ease forwards' : 'fadeIn 250ms ease' }}
@@ -66,13 +66,19 @@ function DrawerShell({
66
66
  const headerTitle = headerOverride?.title ?? getPageTitle(pathname);
67
67
  const headerHidden = headerOverride?.hidden ?? false;
68
68
 
69
+ const handleBack = useCallback(() => {
70
+ const explicit = headerOverride?.backTo ?? getBackPath?.(pathname);
71
+ if (explicit) {
72
+ navigate(explicit);
73
+ } else {
74
+ // Use browser history to go back to the actual previous page
75
+ navigate(-1);
76
+ }
77
+ }, [navigate, headerOverride?.backTo, getBackPath, pathname]);
78
+
69
79
  const defaultLeft = nested
70
80
  ? (
71
- <Button
72
- variant="ghost"
73
- size="icon"
74
- onClick={() => navigate(headerOverride?.backTo ?? getBackPath?.(pathname) ?? '/')}
75
- >
81
+ <Button variant="ghost" size="icon" onClick={handleBack}>
76
82
  {backIcon}
77
83
  </Button>
78
84
  )
@@ -2,6 +2,7 @@ import { cn } from '../utils/cn';
2
2
  import { useRef, useState, useCallback } from 'react';
3
3
  import type { ReactNode, TouchEvent as ReactTouchEvent } from 'react';
4
4
  import { IcEditTrash } from '../icons/svg-icons';
5
+ import { Loading } from './loading';
5
6
 
6
7
  interface ListItemProps {
7
8
  title: string;
@@ -9,7 +10,7 @@ interface ListItemProps {
9
10
  leading?: ReactNode;
10
11
  trailing?: ReactNode;
11
12
  onPress?: () => void;
12
- onDelete?: () => void;
13
+ onDelete?: () => void | Promise<void>;
13
14
  className?: string;
14
15
  }
15
16
 
@@ -20,22 +21,23 @@ const DIRECTION_LOCK_PX = 10;
20
21
  function ListItem({ title, subtitle, leading, trailing, onPress, onDelete, className }: ListItemProps) {
21
22
  const [offset, setOffset] = useState(0);
22
23
  const [swiping, setSwiping] = useState(false);
24
+ const [deleting, setDeleting] = useState(false);
23
25
  const startX = useRef(0);
24
26
  const startY = useRef(0);
25
27
  const currentOffset = useRef(0);
26
28
  const direction = useRef<'none' | 'horizontal' | 'vertical'>('none');
27
29
 
28
30
  const onTouchStart = useCallback((e: ReactTouchEvent) => {
29
- if (!onDelete) return;
31
+ if (!onDelete || deleting) return;
30
32
  startX.current = e.touches[0].clientX;
31
33
  startY.current = e.touches[0].clientY;
32
34
  currentOffset.current = offset;
33
35
  direction.current = 'none';
34
36
  setSwiping(true);
35
- }, [onDelete, offset]);
37
+ }, [deleting, onDelete, offset]);
36
38
 
37
39
  const onTouchMove = useCallback((e: ReactTouchEvent) => {
38
- if (!swiping) return;
40
+ if (!swiping || deleting) return;
39
41
  const dx = e.touches[0].clientX - startX.current;
40
42
  const dy = e.touches[0].clientY - startY.current;
41
43
 
@@ -52,7 +54,7 @@ function ListItem({ title, subtitle, leading, trailing, onPress, onDelete, class
52
54
 
53
55
  const next = Math.min(0, Math.max(-DELETE_WIDTH, currentOffset.current + dx));
54
56
  setOffset(next);
55
- }, [swiping]);
57
+ }, [deleting, swiping]);
56
58
 
57
59
  const onTouchEnd = useCallback(() => {
58
60
  if (!swiping) return;
@@ -61,6 +63,18 @@ function ListItem({ title, subtitle, leading, trailing, onPress, onDelete, class
61
63
  setOffset(offset < -SWIPE_THRESHOLD / 2 ? -DELETE_WIDTH : 0);
62
64
  }, [swiping, offset]);
63
65
 
66
+ const handleDeleteClick = useCallback(async () => {
67
+ if (!onDelete || deleting) return;
68
+ setDeleting(true);
69
+ try {
70
+ await Promise.resolve(onDelete());
71
+ } finally {
72
+ setDeleting(false);
73
+ setOffset(0);
74
+ direction.current = 'none';
75
+ }
76
+ }, [deleting, onDelete]);
77
+
64
78
  const Comp = onPress ? 'button' : 'div';
65
79
 
66
80
  return (
@@ -68,22 +82,23 @@ function ListItem({ title, subtitle, leading, trailing, onPress, onDelete, class
68
82
  {onDelete && offset < 0 && (
69
83
  <button
70
84
  type="button"
71
- onClick={onDelete}
72
- className="absolute right-0 top-0 bottom-0 flex items-center justify-center bg-negative text-text-highlight cursor-pointer"
85
+ onClick={handleDeleteClick}
86
+ disabled={deleting}
87
+ className="absolute right-0 top-0 bottom-0 flex items-center justify-center bg-negative text-text-highlight cursor-pointer disabled:cursor-default"
73
88
  style={{ width: DELETE_WIDTH }}
74
89
  >
75
- <IcEditTrash width={20} height={20} />
90
+ {deleting ? <Loading size={18} className="text-text-highlight" /> : <IcEditTrash width={20} height={20} />}
76
91
  </button>
77
92
  )}
78
93
  <Comp
79
94
  type={onPress ? 'button' : undefined}
80
- onClick={onPress}
95
+ onClick={deleting ? undefined : onPress}
81
96
  onTouchStart={onTouchStart}
82
97
  onTouchMove={onTouchMove}
83
98
  onTouchEnd={onTouchEnd}
84
99
  className={cn(
85
100
  'flex items-center gap-4 w-full bg-surface p-4 text-left transition-colors relative',
86
- onPress && 'cursor-pointer hover:bg-surface-light',
101
+ onPress && !deleting && 'cursor-pointer hover:bg-surface-light',
87
102
  className,
88
103
  )}
89
104
  style={{