examples/vite-vue/src/components/connect-wallet.js 17.9 K raw
1
/**
2
 * @fileoverview ConnectWallet Web Component - A customizable wallet connection component
3
 * that supports Ethereum wallet integration with ENS resolution, balance display, and
4
 * multi-chain support.
5
 *
6
 */
7
8
/**
9
 * ConnectWallet - A Web Component for Ethereum wallet connection and management
10
 *
11
 * This component provides a complete wallet connection interface with the following features:
12
 * - Connect/disconnect wallet functionality
13
 * - ENS name resolution and avatar display
14
 * - Balance fetching and display
15
 * - Multi-chain support with automatic switching
16
 * - Customizable styling through attributes
17
 * - Popover interface for wallet management
18
 *
19
 * @class ConnectWallet
20
 * @extends HTMLElement
21
 *
22
 * @example
23
 * // Basic usage
24
 * <connect-wallet></connect-wallet>
25
 *
26
 * @example
27
 * // With custom styling and chain
28
 * <connect-wallet
29
 *   chain-id="0x89"
30
 *   primary="#4F46E5"
31
 *   background="#1F2937"
32
 *   border-radius="8px">
33
 * </connect-wallet>
34
 *
35
 * @fires ConnectWallet#wallet-connected - Fired when wallet is successfully connected
36
 * @fires ConnectWallet#wallet-disconnected - Fired when wallet is disconnected
37
 * @fires ConnectWallet#wallet-error - Fired when wallet connection fails
38
 */
39
40
class ConnectWallet extends HTMLElement {
41
	// Constructor and lifecycle methods
42
	constructor() {
43
		super();
44
		this.attachShadow({ mode: "open" });
45
		this.connected = false;
46
		this.address = "";
47
		this.ensData = null;
48
		this.loading = false;
49
		this.chainId = "0x1";
50
		this.currentChainId = null;
51
		this.showPopover = false;
52
		this.balance = "0";
53
		this.copySuccess = false;
54
55
		// React-friendly callback properties
56
		this.onWalletConnected = null;
57
		this.onWalletDisconnected = null;
58
		this.onWalletError = null;
59
	}
60
61
	static get observedAttributes() {
62
		return [
63
			"chain-id",
64
			"background",
65
			"foreground",
66
			"primary",
67
			"secondary",
68
			"border-radius",
69
		];
70
	}
71
72
	attributeChangedCallback(name, oldValue, newValue) {
73
		if (name === "chain-id" && oldValue !== newValue) {
74
			this.chainId = this.normalizeChainId(newValue);
75
			if (this.connected) {
76
				this.checkAndSwitchChain();
77
			}
78
		} else if (
79
			[
80
				"background",
81
				"foreground",
82
				"primary",
83
				"secondary",
84
				"border-radius",
85
			].includes(name) &&
86
			oldValue !== newValue
87
		) {
88
			this.render();
89
		}
90
	}
91
92
	connectedCallback() {
93
		const chainIdAttr = this.getAttribute("chain-id");
94
		this.chainId = this.normalizeChainId(chainIdAttr);
95
		this.render();
96
	}
97
98
	// Wallet connection methods
99
	async connect() {
100
		if (window.ethereum) {
101
			try {
102
				this.loading = true;
103
				this.render();
104
105
				const accounts = await window.ethereum.request({
106
					method: "eth_requestAccounts",
107
				});
108
109
				this.address = accounts[0];
110
111
				this.currentChainId = await window.ethereum.request({
112
					method: "eth_chainId",
113
				});
114
115
				if (this.chainId && this.chainId !== this.currentChainId) {
116
					await this.switchChain(this.chainId);
117
				}
118
119
				this.connected = true;
120
121
				await Promise.all([this.fetchEnsData(), this.fetchBalance()]);
122
123
				this.loading = false;
124
				this.render();
125
126
				const eventDetail = {
127
					address: this.address,
128
					ensData: this.ensData,
129
					chainId: this.currentChainId,
130
				};
131
132
				// Dispatch custom event for vanilla JS and other frameworks
133
				this.dispatchEvent(
134
					new CustomEvent("wallet-connected", {
135
						detail: eventDetail,
136
					}),
137
				);
138
139
				// Call callback property if set (React-friendly)
140
				if (typeof this.onWalletConnected === "function") {
141
					this.onWalletConnected(eventDetail);
142
				}
143
			} catch (error) {
144
				console.error("Connection failed", error);
145
				this.loading = false;
146
				this.render();
147
148
				const errorDetail = { error: error.message };
149
150
				// Dispatch custom event for vanilla JS and other frameworks
151
				this.dispatchEvent(
152
					new CustomEvent("wallet-error", {
153
						detail: errorDetail,
154
					}),
155
				);
156
157
				// Call callback property if set (React-friendly)
158
				if (typeof this.onWalletError === "function") {
159
					this.onWalletError(errorDetail);
160
				}
161
			}
162
		} else {
163
			alert("Please install a wallet extension like MetaMask");
164
		}
165
	}
166
167
	disconnect() {
168
		this.connected = false;
169
		this.address = "";
170
		this.ensData = null;
171
		this.currentChainId = null;
172
		this.balance = "0";
173
		this.showPopover = false;
174
		this.copySuccess = false;
175
		this.render();
176
177
		// Dispatch custom event for vanilla JS and other frameworks
178
		this.dispatchEvent(new CustomEvent("wallet-disconnected"));
179
180
		// Call callback property if set (React-friendly)
181
		if (typeof this.onWalletDisconnected === "function") {
182
			this.onWalletDisconnected();
183
		}
184
	}
185
186
	// Chain management methods
187
	/**
188
	 * Converts a numeric chain ID to hex format
189
	 * @param {string|number} chainId - Chain ID in numeric or hex format
190
	 * @returns {string} Chain ID in hex format (e.g., "0x2105")
191
	 */
192
	normalizeChainId(chainId) {
193
		if (!chainId) return "0x1";
194
195
		const chainIdStr = String(chainId);
196
197
		// If it's already in hex format (starts with 0x), return as-is
198
		if (chainIdStr.startsWith("0x")) {
199
			return chainIdStr.toLowerCase();
200
		}
201
202
		// Convert numeric string to hex
203
		const numericChainId = parseInt(chainIdStr, 10);
204
		if (isNaN(numericChainId)) {
205
			console.warn(`Invalid chain ID: ${chainId}, defaulting to 0x1`);
206
			return "0x1";
207
		}
208
209
		return `0x${numericChainId.toString(16)}`;
210
	}
211
212
	async switchChain(chainId) {
213
		try {
214
			await window.ethereum.request({
215
				method: "wallet_switchEthereumChain",
216
				params: [{ chainId }],
217
			});
218
			this.currentChainId = chainId;
219
		} catch (switchError) {
220
			throw new Error(`Failed to switch chain: ${switchError.message}`);
221
		}
222
	}
223
224
	async checkAndSwitchChain() {
225
		if (window.ethereum && this.chainId && this.connected) {
226
			const currentChain = await window.ethereum.request({
227
				method: "eth_chainId",
228
			});
229
230
			if (currentChain !== this.chainId) {
231
				try {
232
					await this.switchChain(this.chainId);
233
					this.render();
234
				} catch (error) {
235
					console.error("Failed to switch chain:", error);
236
				}
237
			}
238
		}
239
	}
240
241
	getChainName(chainId) {
242
		const chainNames = {
243
			"0x1": "Ethereum",
244
			"0x89": "Polygon",
245
			"0xa": "Optimism",
246
			"0xa4b1": "Arbitrum",
247
			"0x2105": "Base",
248
		};
249
		return chainNames[chainId] || `Chain ${chainId}`;
250
	}
251
252
	// Data fetching methods
253
	async fetchEnsData() {
254
		try {
255
			const response = await fetch(`https://api.ensdata.net/${this.address}`);
256
			if (response.ok) {
257
				this.ensData = await response.json();
258
				console.log("ENS data loaded:", this.ensData);
259
			} else {
260
				console.log("No ENS data found for this address");
261
				this.ensData = null;
262
			}
263
		} catch (error) {
264
			console.error("Failed to fetch ENS data", error);
265
			this.ensData = null;
266
		}
267
	}
268
269
	async fetchBalance() {
270
		try {
271
			const balanceWei = await window.ethereum.request({
272
				method: "eth_getBalance",
273
				params: [this.address, "latest"],
274
			});
275
276
			const balanceEth = parseInt(balanceWei, 16) / Math.pow(10, 18);
277
			this.balance = balanceEth.toFixed(4);
278
		} catch (error) {
279
			console.error("Failed to fetch balance", error);
280
			this.balance = "0";
281
		}
282
	}
283
284
	// UI helper methods
285
	getDisplayName() {
286
		if (this.ensData?.ens_primary) {
287
			return this.ensData.ens_primary;
288
		}
289
		return this.truncateAddress(this.address);
290
	}
291
292
	truncateAddress(addr) {
293
		if (!addr) return "";
294
		return addr.slice(0, 5) + "..." + addr.slice(-5);
295
	}
296
297
	async copyAddress() {
298
		try {
299
			await navigator.clipboard.writeText(this.address);
300
			this.copySuccess = true;
301
			this.showPopoverElement();
302
303
			setTimeout(() => {
304
				this.copySuccess = false;
305
				this.showPopoverElement();
306
			}, 1000);
307
		} catch (error) {
308
			console.error("Failed to copy address", error);
309
		}
310
	}
311
312
	// Popover management methods
313
	togglePopover() {
314
		this.showPopover = !this.showPopover;
315
		if (this.showPopover) {
316
			this.showPopoverElement();
317
		} else {
318
			this.hidePopoverElement();
319
		}
320
	}
321
322
	hidePopover() {
323
		if (this.showPopover) {
324
			this.showPopover = false;
325
			this.hidePopoverElement();
326
		}
327
	}
328
329
	showPopoverElement() {
330
		const profileContainer =
331
			this.shadowRoot.querySelector(".profile-container");
332
		if (!profileContainer) return;
333
334
		const existingPopover = profileContainer.querySelector(".popover");
335
		if (existingPopover) {
336
			existingPopover.remove();
337
		}
338
339
		const popover = document.createElement("div");
340
		popover.className = "popover";
341
342
		const copyIcon = this.copySuccess
343
			? `<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.4669 3.72684C11.7558 3.91574 11.8369 4.30308 11.648 4.59198L7.39799 11.092C7.29783 11.2452 7.13556 11.3467 6.95402 11.3699C6.77247 11.3931 6.58989 11.3355 6.45446 11.2124L3.70446 8.71241C3.44905 8.48022 3.43023 8.08494 3.66242 7.82953C3.89461 7.57412 4.28989 7.55529 4.5453 7.78749L6.75292 9.79441L10.6018 3.90792C10.7907 3.61902 11.178 3.53795 11.4669 3.72684Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>`
344
			: `<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 2V1H10V2H5ZM4.75 0C4.33579 0 4 0.335786 4 0.75V1H3.5C2.67157 1 2 1.67157 2 2.5V12.5C2 13.3284 2.67157 14 3.5 14H7V13H3.5C3.22386 13 3 12.7761 3 12.5V2.5C3 2.22386 3.22386 2 3.5 2H4V2.25C4 2.66421 4.33579 3 4.75 3H10.25C10.6642 3 11 2.66421 11 2.25V2H11.5C11.7761 2 12 2.22386 12 2.5V7H13V2.5C13 1.67157 12.3284 1 11.5 1H11V0.75C11 0.335786 10.6642 0 10.25 0H4.75ZM9 8.5C9 8.77614 8.77614 9 8.5 9C8.22386 9 8 8.77614 8 8.5C8 8.22386 8.22386 8 8.5 8C8.77614 8 9 8.22386 9 8.5ZM10.5 9C10.7761 9 11 8.77614 11 8.5C11 8.22386 10.7761 8 10.5 8C10.2239 8 10 8.22386 10 8.5C10 8.77614 10.2239 9 10.5 9ZM13 8.5C13 8.77614 12.7761 9 12.5 9C12.2239 9 12 8.77614 12 8.5C12 8.22386 12.2239 8 12.5 8C12.7761 8 13 8.22386 13 8.5ZM14.5 9C14.7761 9 15 8.77614 15 8.5C15 8.22386 14.7761 8 14.5 8C14.2239 8 14 8.22386 14 8.5C14 8.77614 14.2239 9 14.5 9ZM15 10.5C15 10.7761 14.7761 11 14.5 11C14.2239 11 14 10.7761 14 10.5C14 10.2239 14.2239 10 14.5 10C14.7761 10 15 10.2239 15 10.5ZM14.5 13C14.7761 13 15 12.7761 15 12.5C15 12.2239 14.7761 12 14.5 12C14.2239 12 14 12.2239 14 12.5C14 12.7761 14.2239 13 14.5 13ZM14.5 15C14.7761 15 15 14.7761 15 14.5C15 14.2239 14.7761 14 14.5 14C14.2239 14 14 14.2239 14 14.5C14 14.7761 14.2239 15 14.5 15ZM8.5 11C8.77614 11 9 10.7761 9 10.5C9 10.2239 8.77614 10 8.5 10C8.22386 10 8 10.2239 8 10.5C8 10.7761 8.22386 11 8.5 11ZM9 12.5C9 12.7761 8.77614 13 8.5 13C8.22386 13 8 12.7761 8 12.5C8 12.2239 8.22386 12 8.5 12C8.77614 12 9 12.2239 9 12.5ZM8.5 15C8.77614 15 9 14.7761 9 14.5C9 14.2239 8.77614 14 8.5 14C8.22386 14 8 14.2239 8 14.5C8 14.7761 8.22386 15 8.5 15ZM11 14.5C11 14.7761 10.7761 15 10.5 15C10.2239 15 10 14.7761 10 14.5C10 14.2239 10.2239 14 10.5 14C10.7761 14 11 14.2239 11 14.5ZM12.5 15C12.7761 15 13 14.7761 13 14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5C12 12.7761 12.2239 15 12.5 15Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>`;
345
346
		const copyText = this.copySuccess ? "Copied!" : "Copy Address";
347
		popover.innerHTML = `
348
			<button class="popover-button copy-button">
349
				<span>${copyIcon}</span>
350
				${copyText}
351
			</button>
352
			<button class="popover-button disconnect-button">
353
				<span><svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 1C2.44771 1 2 1.44772 2 2V13C2 13.5523 2.44772 14 3 14H10.5C10.7761 14 11 13.7761 11 13.5C11 13.2239 10.7761 13 10.5 13H3V2L10.5 2C10.7761 2 11 1.77614 11 1.5C11 1.22386 10.7761 1 10.5 1H3ZM12.6036 4.89645C12.4083 4.70118 12.0917 4.70118 11.8964 4.89645C11.7012 5.09171 11.7012 5.40829 11.8964 5.60355L13.2929 7H6.5C6.22386 7 6 7.22386 6 7.5C6 7.77614 6.22386 8 6.5 8H13.2929L11.8964 9.39645C11.7012 9.59171 11.7012 9.90829 11.8964 10.1036C12.0917 10.2988 12.4083 10.2988 12.6036 10.1036L14.8536 7.85355C15.0488 7.65829 15.0488 7.34171 14.8536 7.14645L12.6036 4.89645Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg></span>
354
				Disconnect
355
			</button>
356
		`;
357
358
		popover.querySelector(".copy-button").addEventListener("click", (e) => {
359
			e.stopPropagation();
360
			this.copyAddress();
361
		});
362
363
		popover
364
			.querySelector(".disconnect-button")
365
			.addEventListener("click", (e) => {
366
				e.stopPropagation();
367
				this.disconnect();
368
			});
369
370
		profileContainer.appendChild(popover);
371
372
		setTimeout(() => {
373
			document.addEventListener("click", this.hidePopover.bind(this), {
374
				once: true,
375
			});
376
		}, 0);
377
	}
378
379
	hidePopoverElement() {
380
		const profileContainer =
381
			this.shadowRoot.querySelector(".profile-container");
382
		if (!profileContainer) return;
383
384
		const popover = profileContainer.querySelector(".popover");
385
		if (popover) {
386
			popover.remove();
387
		}
388
	}
389
390
	// Color helper methods
391
	getCSSVariable(name, defaultValue) {
392
		return this.getAttribute(name) || defaultValue;
393
	}
394
395
	// Render methods and styling
396
	render() {
397
		const background = this.getCSSVariable("background", "#232323");
398
		const foreground = this.getCSSVariable("foreground", "#ffffff");
399
		const primary = this.getCSSVariable("primary", "#5F8787");
400
		const secondary = this.getCSSVariable("secondary", "#6F9797");
401
		const borderRadius = this.getCSSVariable("border-radius", "4px");
402
403
		this.shadowRoot.innerHTML = `
404
			<style>
405
				:host {
406
					--color-background: ${background};
407
					--color-foreground: ${foreground};
408
					--color-primary: ${primary};
409
					--color-secondary: ${secondary};
410
					--border-radius: ${borderRadius};
411
					--bg-color: ${this.connected ? "var(--color-background)" : "var(--color-primary)"};
412
					--bg-hover-color: ${this.connected ? "var(--color-background)" : "var(--color-secondary)"};
413
					display: inline-block;
414
				}
415
416
				button {
417
					padding: 10px 20px;
418
					background: var(--bg-color);
419
					color: var(--color-foreground);
420
					border: none;
421
					border-radius: var(--border-radius);
422
					cursor: pointer;
423
					font-size: 16px;
424
					transition: background-color 0.3s ease;
425
				}
426
427
				button:hover {
428
					background: var(--bg-hover-color);
429
				}
430
431
				button:disabled {
432
					opacity: 0.7;
433
					cursor: not-allowed;
434
				}
435
436
				.profile-container {
437
					position: relative;
438
					display: inline-block;
439
					font-family: sans-serif;
440
				}
441
442
				.profile {
443
					display: flex;
444
					align-items: center;
445
					gap: 8px;
446
					padding: 10px 20px;
447
					background: var(--bg-color);
448
					border-radius: var(--border-radius);
449
					color: var(--color-foreground);
450
					min-width: auto;
451
					transition: background-color 0.3s ease;
452
					cursor: pointer;
453
				}
454
455
				.profile:hover {
456
					background: var(--bg-hover-color);
457
				}
458
459
				.popover {
460
					position: absolute;
461
					top: 100%;
462
					left: 0;
463
					right: 0;
464
					background: var(--bg-color);
465
					border: 1px solid rgba(255, 255, 255, 0.1);
466
					border-radius: var(--border-radius);
467
					box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
468
					z-index: 1000;
469
					margin-top: 4px;
470
					overflow: hidden;
471
				}
472
473
				.popover-button {
474
					display: flex;
475
					align-items: center;
476
					gap: 8px;
477
					width: 100%;
478
					padding: 10px 16px;
479
					background: var(--bg-color);
480
					border: none;
481
					color: var(--color-foreground);
482
					font-size: 14px;
483
					cursor: pointer;
484
					transition: background-color 0.2s ease;
485
				}
486
487
				.popover-button:hover {
488
					background: var(--bg-hover-color);
489
				}
490
491
				.popover-button:not(:last-child) {
492
					border-bottom: 1px solid rgba(255, 255, 255, 0.1);
493
				}
494
495
				.popover-button span {
496
					font-size: 16px;
497
				}
498
499
				.avatar {
500
					width: 32px;
501
					height: 32px;
502
					border-radius: 50%;
503
					object-fit: cover;
504
				}
505
506
				.avatar-placeholder {
507
					width: 32px;
508
					height: 32px;
509
					border-radius: 50%;
510
					background: linear-gradient(45deg, var(--color-primary), var(--color-secondary));
511
					display: flex;
512
					align-items: center;
513
					justify-content: center;
514
					color: var(--color-foreground);
515
					font-weight: bold;
516
					font-size: 12px;
517
				}
518
519
				.profile-info {
520
					flex: 1;
521
					min-width: 0;
522
				}
523
524
				.profile-info h4 {
525
					margin: 0 0 2px 0;
526
					font-size: 14px;
527
					font-weight: 600;
528
					white-space: nowrap;
529
					overflow: hidden;
530
					text-overflow: ellipsis;
531
				}
532
533
				.profile-info p {
534
					margin: 0;
535
					font-size: 12px;
536
					opacity: 0.8;
537
					white-space: nowrap;
538
					overflow: hidden;
539
					text-overflow: ellipsis;
540
					font-family: monospace;
541
				}
542
543
				.loading {
544
					display: flex;
545
					align-items: center;
546
					gap: 8px;
547
				}
548
549
				.spinner {
550
					width: 16px;
551
					height: 16px;
552
					border: 2px solid rgba(255, 255, 255, 0.3);
553
					border-top: 2px solid var(--color-foreground);
554
					border-radius: 50%;
555
					animation: spin 1s linear infinite;
556
				}
557
558
				@keyframes spin {
559
					from { transform: rotate(0deg); }
560
					to { transform: rotate(360deg); }
561
				}
562
			</style>
563
		`;
564
565
		if (this.loading) {
566
			this.renderLoading();
567
		} else if (this.connected) {
568
			this.renderProfile();
569
		} else {
570
			this.renderConnectButton();
571
		}
572
	}
573
574
	renderProfile() {
575
		const profileContainer = document.createElement("div");
576
		profileContainer.className = "profile-container";
577
578
		const profileDiv = document.createElement("div");
579
		profileDiv.className = "profile";
580
581
		const avatar = this.ensData?.avatar_small;
582
		const displayName = this.getDisplayName();
583
584
		let avatarElement = "";
585
		if (avatar) {
586
			avatarElement = `<img src="${avatar}" alt="Avatar" class="avatar" onerror="this.style.display='none'">`;
587
		} else {
588
			avatarElement = `<div class="avatar-placeholder"></div>`;
589
		}
590
591
		profileDiv.innerHTML = `
592
			${avatarElement}
593
			<div class="profile-info">
594
				<h4>${displayName}</h4>
595
				<p>${this.balance} ETH</p>
596
			</div>
597
		`;
598
599
		profileDiv.addEventListener("click", (e) => {
600
			e.stopPropagation();
601
			this.togglePopover();
602
		});
603
604
		profileContainer.appendChild(profileDiv);
605
		this.shadowRoot.appendChild(profileContainer);
606
607
		if (this.showPopover) {
608
			this.showPopoverElement();
609
		}
610
	}
611
612
	renderLoading() {
613
		const button = document.createElement("button");
614
		button.disabled = true;
615
		button.innerHTML = `
616
			<div class="loading">
617
				<div class="spinner"></div>
618
				<span>Connecting...</span>
619
			</div>
620
		`;
621
		this.shadowRoot.appendChild(button);
622
	}
623
624
	renderConnectButton() {
625
		const button = document.createElement("button");
626
		button.textContent = "Connect Wallet";
627
		button.addEventListener("click", () => this.connect());
628
		this.shadowRoot.appendChild(button);
629
	}
630
}
631
632
customElements.define("connect-wallet", ConnectWallet);