@sip-protocol/react 0.1.0 → 0.1.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.
@@ -0,0 +1,548 @@
1
+ import React, { useState, useCallback, useId, useMemo } from 'react'
2
+
3
+ /**
4
+ * Privacy level options
5
+ */
6
+ export type PrivacyLevel = 'off' | 'shielded' | 'compliant'
7
+
8
+ /**
9
+ * Gas estimate for different privacy levels
10
+ */
11
+ export interface GasEstimate {
12
+ /** Gas in native units (e.g., yoctoNEAR, gwei) */
13
+ gas: string
14
+ /** Formatted cost in native token */
15
+ cost: string
16
+ /** Formatted cost in USD (optional) */
17
+ costUsd?: string
18
+ }
19
+
20
+ /**
21
+ * Privacy level metadata
22
+ */
23
+ export interface PrivacyLevelInfo {
24
+ level: PrivacyLevel
25
+ label: string
26
+ description: string
27
+ icon: React.ReactNode
28
+ gasEstimate?: GasEstimate
29
+ }
30
+
31
+ /**
32
+ * PrivacyToggle component props
33
+ */
34
+ export interface PrivacyToggleProps {
35
+ /** Current privacy level (controlled mode) */
36
+ value?: PrivacyLevel
37
+ /** Default privacy level (uncontrolled mode) */
38
+ defaultValue?: PrivacyLevel
39
+ /** Callback when privacy level changes */
40
+ onChange?: (level: PrivacyLevel) => void
41
+ /** Whether the toggle is disabled */
42
+ disabled?: boolean
43
+ /** Gas estimates for each level */
44
+ gasEstimates?: Partial<Record<PrivacyLevel, GasEstimate>>
45
+ /** Show gas/fee difference */
46
+ showGasEstimate?: boolean
47
+ /** Show tooltips */
48
+ showTooltips?: boolean
49
+ /** Custom class name */
50
+ className?: string
51
+ /** Size variant */
52
+ size?: 'sm' | 'md' | 'lg'
53
+ /** Chain identifier for context */
54
+ chain?: string
55
+ /** Aria label for the toggle group */
56
+ 'aria-label'?: string
57
+ }
58
+
59
+ /**
60
+ * Default privacy level descriptions
61
+ */
62
+ const DEFAULT_LEVEL_INFO: Record<PrivacyLevel, Omit<PrivacyLevelInfo, 'level' | 'gasEstimate'>> = {
63
+ off: {
64
+ label: 'Public',
65
+ description: 'Transaction details are fully visible on-chain. Sender, recipient, and amount are public.',
66
+ icon: (
67
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="sip-privacy-icon">
68
+ <circle cx="12" cy="12" r="10" />
69
+ <path d="M12 6v6l4 2" />
70
+ </svg>
71
+ ),
72
+ },
73
+ shielded: {
74
+ label: 'Shielded',
75
+ description: 'Full privacy. Sender, recipient, and amount are hidden using stealth addresses and commitments.',
76
+ icon: (
77
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="sip-privacy-icon">
78
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
79
+ </svg>
80
+ ),
81
+ },
82
+ compliant: {
83
+ label: 'Compliant',
84
+ description: 'Privacy with viewing keys. Transaction is shielded but can be audited by authorized parties.',
85
+ icon: (
86
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="sip-privacy-icon">
87
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
88
+ <path d="M9 12l2 2 4-4" />
89
+ </svg>
90
+ ),
91
+ },
92
+ }
93
+
94
+ /**
95
+ * CSS styles for the component (can be overridden via className)
96
+ */
97
+ const styles = `
98
+ .sip-privacy-toggle {
99
+ display: inline-flex;
100
+ flex-direction: column;
101
+ gap: 8px;
102
+ font-family: system-ui, -apple-system, sans-serif;
103
+ }
104
+
105
+ .sip-privacy-toggle-group {
106
+ display: inline-flex;
107
+ background: #f3f4f6;
108
+ border-radius: 12px;
109
+ padding: 4px;
110
+ gap: 4px;
111
+ }
112
+
113
+ .sip-privacy-toggle-group[data-size="sm"] {
114
+ padding: 2px;
115
+ gap: 2px;
116
+ border-radius: 8px;
117
+ }
118
+
119
+ .sip-privacy-toggle-group[data-size="lg"] {
120
+ padding: 6px;
121
+ gap: 6px;
122
+ border-radius: 16px;
123
+ }
124
+
125
+ .sip-privacy-option {
126
+ position: relative;
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 6px;
130
+ padding: 8px 16px;
131
+ border: none;
132
+ background: transparent;
133
+ border-radius: 8px;
134
+ cursor: pointer;
135
+ font-size: 14px;
136
+ font-weight: 500;
137
+ color: #6b7280;
138
+ transition: all 0.2s ease;
139
+ outline: none;
140
+ }
141
+
142
+ .sip-privacy-option[data-size="sm"] {
143
+ padding: 6px 12px;
144
+ font-size: 12px;
145
+ border-radius: 6px;
146
+ }
147
+
148
+ .sip-privacy-option[data-size="lg"] {
149
+ padding: 12px 24px;
150
+ font-size: 16px;
151
+ border-radius: 12px;
152
+ }
153
+
154
+ .sip-privacy-option:hover:not(:disabled) {
155
+ color: #374151;
156
+ background: rgba(0, 0, 0, 0.05);
157
+ }
158
+
159
+ .sip-privacy-option:focus-visible {
160
+ box-shadow: 0 0 0 2px #3b82f6;
161
+ }
162
+
163
+ .sip-privacy-option[aria-checked="true"] {
164
+ background: white;
165
+ color: #111827;
166
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
167
+ }
168
+
169
+ .sip-privacy-option[aria-checked="true"][data-level="off"] {
170
+ color: #6b7280;
171
+ }
172
+
173
+ .sip-privacy-option[aria-checked="true"][data-level="shielded"] {
174
+ color: #059669;
175
+ }
176
+
177
+ .sip-privacy-option[aria-checked="true"][data-level="compliant"] {
178
+ color: #2563eb;
179
+ }
180
+
181
+ .sip-privacy-option:disabled {
182
+ opacity: 0.5;
183
+ cursor: not-allowed;
184
+ }
185
+
186
+ .sip-privacy-icon {
187
+ width: 16px;
188
+ height: 16px;
189
+ }
190
+
191
+ .sip-privacy-option[data-size="sm"] .sip-privacy-icon {
192
+ width: 14px;
193
+ height: 14px;
194
+ }
195
+
196
+ .sip-privacy-option[data-size="lg"] .sip-privacy-icon {
197
+ width: 20px;
198
+ height: 20px;
199
+ }
200
+
201
+ .sip-privacy-tooltip {
202
+ position: absolute;
203
+ bottom: calc(100% + 8px);
204
+ left: 50%;
205
+ transform: translateX(-50%);
206
+ padding: 8px 12px;
207
+ background: #1f2937;
208
+ color: white;
209
+ font-size: 12px;
210
+ font-weight: 400;
211
+ border-radius: 6px;
212
+ white-space: nowrap;
213
+ max-width: 250px;
214
+ white-space: normal;
215
+ text-align: center;
216
+ opacity: 0;
217
+ visibility: hidden;
218
+ transition: opacity 0.2s, visibility 0.2s;
219
+ z-index: 10;
220
+ pointer-events: none;
221
+ }
222
+
223
+ .sip-privacy-tooltip::after {
224
+ content: '';
225
+ position: absolute;
226
+ top: 100%;
227
+ left: 50%;
228
+ transform: translateX(-50%);
229
+ border: 6px solid transparent;
230
+ border-top-color: #1f2937;
231
+ }
232
+
233
+ .sip-privacy-option:hover .sip-privacy-tooltip,
234
+ .sip-privacy-option:focus .sip-privacy-tooltip {
235
+ opacity: 1;
236
+ visibility: visible;
237
+ }
238
+
239
+ .sip-privacy-gas-info {
240
+ display: flex;
241
+ align-items: center;
242
+ gap: 8px;
243
+ font-size: 12px;
244
+ color: #6b7280;
245
+ padding: 0 4px;
246
+ }
247
+
248
+ .sip-privacy-gas-badge {
249
+ display: inline-flex;
250
+ align-items: center;
251
+ gap: 4px;
252
+ padding: 2px 8px;
253
+ background: #f3f4f6;
254
+ border-radius: 4px;
255
+ font-size: 11px;
256
+ }
257
+
258
+ .sip-privacy-gas-badge[data-level="shielded"],
259
+ .sip-privacy-gas-badge[data-level="compliant"] {
260
+ background: #fef3c7;
261
+ color: #92400e;
262
+ }
263
+
264
+ @keyframes sip-privacy-pulse {
265
+ 0%, 100% { transform: scale(1); }
266
+ 50% { transform: scale(1.05); }
267
+ }
268
+
269
+ .sip-privacy-option[aria-checked="true"] {
270
+ animation: sip-privacy-pulse 0.3s ease;
271
+ }
272
+
273
+ /* Dark mode support */
274
+ @media (prefers-color-scheme: dark) {
275
+ .sip-privacy-toggle-group {
276
+ background: #374151;
277
+ }
278
+
279
+ .sip-privacy-option {
280
+ color: #9ca3af;
281
+ }
282
+
283
+ .sip-privacy-option:hover:not(:disabled) {
284
+ color: #e5e7eb;
285
+ background: rgba(255, 255, 255, 0.05);
286
+ }
287
+
288
+ .sip-privacy-option[aria-checked="true"] {
289
+ background: #4b5563;
290
+ color: #f9fafb;
291
+ }
292
+
293
+ .sip-privacy-gas-info {
294
+ color: #9ca3af;
295
+ }
296
+
297
+ .sip-privacy-gas-badge {
298
+ background: #374151;
299
+ color: #d1d5db;
300
+ }
301
+ }
302
+ `
303
+
304
+ /**
305
+ * PrivacyToggle - Toggle component for selecting privacy level
306
+ *
307
+ * A three-state toggle for controlling transaction privacy on NEAR and other chains.
308
+ * Supports controlled and uncontrolled modes, accessibility features, and gas estimates.
309
+ *
310
+ * @example Basic usage
311
+ * ```tsx
312
+ * import { PrivacyToggle } from '@sip-protocol/react'
313
+ *
314
+ * function SendForm() {
315
+ * const [privacy, setPrivacy] = useState<PrivacyLevel>('shielded')
316
+ *
317
+ * return (
318
+ * <PrivacyToggle
319
+ * value={privacy}
320
+ * onChange={setPrivacy}
321
+ * />
322
+ * )
323
+ * }
324
+ * ```
325
+ *
326
+ * @example With gas estimates
327
+ * ```tsx
328
+ * <PrivacyToggle
329
+ * value={privacy}
330
+ * onChange={setPrivacy}
331
+ * showGasEstimate
332
+ * gasEstimates={{
333
+ * off: { gas: '2500000000000', cost: '0.00025 NEAR' },
334
+ * shielded: { gas: '30000000000000', cost: '0.003 NEAR' },
335
+ * compliant: { gas: '35000000000000', cost: '0.0035 NEAR' },
336
+ * }}
337
+ * />
338
+ * ```
339
+ *
340
+ * @example Uncontrolled mode
341
+ * ```tsx
342
+ * <PrivacyToggle
343
+ * defaultValue="shielded"
344
+ * onChange={(level) => console.log('Selected:', level)}
345
+ * />
346
+ * ```
347
+ */
348
+ export function PrivacyToggle({
349
+ value,
350
+ defaultValue = 'shielded',
351
+ onChange,
352
+ disabled = false,
353
+ gasEstimates,
354
+ showGasEstimate = false,
355
+ showTooltips = true,
356
+ className = '',
357
+ size = 'md',
358
+ chain = 'near',
359
+ 'aria-label': ariaLabel = 'Privacy level',
360
+ }: PrivacyToggleProps) {
361
+ // Generate unique IDs for accessibility
362
+ const baseId = useId()
363
+
364
+ // Internal state for uncontrolled mode
365
+ const [internalValue, setInternalValue] = useState<PrivacyLevel>(defaultValue)
366
+
367
+ // Determine if controlled or uncontrolled
368
+ const isControlled = value !== undefined
369
+ const currentValue = isControlled ? value : internalValue
370
+
371
+ // Handle selection change
372
+ const handleSelect = useCallback(
373
+ (level: PrivacyLevel) => {
374
+ if (disabled) return
375
+
376
+ if (!isControlled) {
377
+ setInternalValue(level)
378
+ }
379
+
380
+ onChange?.(level)
381
+ },
382
+ [disabled, isControlled, onChange]
383
+ )
384
+
385
+ // Handle keyboard navigation
386
+ const handleKeyDown = useCallback(
387
+ (event: React.KeyboardEvent, currentLevel: PrivacyLevel) => {
388
+ const levels: PrivacyLevel[] = ['off', 'shielded', 'compliant']
389
+ const currentIndex = levels.indexOf(currentLevel)
390
+
391
+ let newIndex = currentIndex
392
+
393
+ switch (event.key) {
394
+ case 'ArrowRight':
395
+ case 'ArrowDown':
396
+ event.preventDefault()
397
+ newIndex = (currentIndex + 1) % levels.length
398
+ break
399
+ case 'ArrowLeft':
400
+ case 'ArrowUp':
401
+ event.preventDefault()
402
+ newIndex = (currentIndex - 1 + levels.length) % levels.length
403
+ break
404
+ case 'Home':
405
+ event.preventDefault()
406
+ newIndex = 0
407
+ break
408
+ case 'End':
409
+ event.preventDefault()
410
+ newIndex = levels.length - 1
411
+ break
412
+ default:
413
+ return
414
+ }
415
+
416
+ handleSelect(levels[newIndex])
417
+ },
418
+ [handleSelect]
419
+ )
420
+
421
+ // Build level info with gas estimates
422
+ const levelInfos: PrivacyLevelInfo[] = useMemo(
423
+ () =>
424
+ (['off', 'shielded', 'compliant'] as PrivacyLevel[]).map((level) => ({
425
+ level,
426
+ ...DEFAULT_LEVEL_INFO[level],
427
+ gasEstimate: gasEstimates?.[level],
428
+ })),
429
+ [gasEstimates]
430
+ )
431
+
432
+ // Calculate gas difference from base (off)
433
+ const getGasDifference = useCallback(
434
+ (level: PrivacyLevel): string | null => {
435
+ if (!gasEstimates?.off || !gasEstimates?.[level]) return null
436
+ if (level === 'off') return null
437
+
438
+ const baseCost = gasEstimates.off.cost
439
+ const levelCost = gasEstimates[level]?.cost
440
+
441
+ if (!levelCost) return null
442
+
443
+ return `+${levelCost} vs ${baseCost}`
444
+ },
445
+ [gasEstimates]
446
+ )
447
+
448
+ return (
449
+ <>
450
+ {/* Inject styles */}
451
+ <style>{styles}</style>
452
+
453
+ <div
454
+ className={`sip-privacy-toggle ${className}`}
455
+ data-chain={chain}
456
+ >
457
+ {/* Toggle group */}
458
+ <div
459
+ role="radiogroup"
460
+ aria-label={ariaLabel}
461
+ className="sip-privacy-toggle-group"
462
+ data-size={size}
463
+ >
464
+ {levelInfos.map((info) => {
465
+ const isSelected = currentValue === info.level
466
+ const optionId = `${baseId}-${info.level}`
467
+
468
+ return (
469
+ <button
470
+ key={info.level}
471
+ id={optionId}
472
+ role="radio"
473
+ aria-checked={isSelected}
474
+ aria-label={`${info.label}: ${info.description}`}
475
+ disabled={disabled}
476
+ onClick={() => handleSelect(info.level)}
477
+ onKeyDown={(e) => handleKeyDown(e, info.level)}
478
+ tabIndex={isSelected ? 0 : -1}
479
+ className="sip-privacy-option"
480
+ data-level={info.level}
481
+ data-size={size}
482
+ >
483
+ {info.icon}
484
+ <span>{info.label}</span>
485
+
486
+ {/* Tooltip */}
487
+ {showTooltips && (
488
+ <span className="sip-privacy-tooltip" role="tooltip">
489
+ {info.description}
490
+ </span>
491
+ )}
492
+ </button>
493
+ )
494
+ })}
495
+ </div>
496
+
497
+ {/* Gas estimate info */}
498
+ {showGasEstimate && gasEstimates && (
499
+ <div className="sip-privacy-gas-info" aria-live="polite">
500
+ <span>Estimated fee:</span>
501
+ <span
502
+ className="sip-privacy-gas-badge"
503
+ data-level={currentValue}
504
+ >
505
+ {gasEstimates[currentValue]?.cost ?? 'Unknown'}
506
+ {gasEstimates[currentValue]?.costUsd && (
507
+ <span> (~{gasEstimates[currentValue]?.costUsd})</span>
508
+ )}
509
+ </span>
510
+ {currentValue !== 'off' && getGasDifference(currentValue) && (
511
+ <span className="sip-privacy-gas-diff">
512
+ ({getGasDifference(currentValue)})
513
+ </span>
514
+ )}
515
+ </div>
516
+ )}
517
+ </div>
518
+ </>
519
+ )
520
+ }
521
+
522
+ /**
523
+ * Hook to use with PrivacyToggle for managing privacy state
524
+ */
525
+ export function usePrivacyToggle(initialValue: PrivacyLevel = 'shielded') {
526
+ const [privacyLevel, setPrivacyLevel] = useState<PrivacyLevel>(initialValue)
527
+
528
+ const isPrivate = privacyLevel !== 'off'
529
+ const isCompliant = privacyLevel === 'compliant'
530
+ const isShielded = privacyLevel === 'shielded'
531
+
532
+ const setPublic = useCallback(() => setPrivacyLevel('off'), [])
533
+ const setShielded = useCallback(() => setPrivacyLevel('shielded'), [])
534
+ const setCompliant = useCallback(() => setPrivacyLevel('compliant'), [])
535
+
536
+ return {
537
+ privacyLevel,
538
+ setPrivacyLevel,
539
+ isPrivate,
540
+ isCompliant,
541
+ isShielded,
542
+ setPublic,
543
+ setShielded,
544
+ setCompliant,
545
+ }
546
+ }
547
+
548
+ export default PrivacyToggle