@dile/utils 3.1.14 → 3.2.0

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 @@
1
+ export { DileQrCode } from './src/DileQrCode.js';
@@ -0,0 +1,5 @@
1
+ import { DileQrCode } from './src/DileQrCode.js';
2
+
3
+ if (!customElements.get('dile-qr-code')) {
4
+ window.customElements.define('dile-qr-code', DileQrCode);
5
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import './qr-code.js';
3
+
4
+ describe('dile-qr-code', () => {
5
+ afterEach(() => {
6
+ document.body.innerHTML = '';
7
+ });
8
+
9
+ async function renderQrCode(html) {
10
+ document.body.innerHTML = html;
11
+ const el = document.body.querySelector('dile-qr-code');
12
+ await el.updateComplete;
13
+ return el;
14
+ }
15
+
16
+ it('renders canvas with default properties', async () => {
17
+ const el = await renderQrCode('<dile-qr-code></dile-qr-code>');
18
+ const canvas = el.shadowRoot.querySelector('canvas');
19
+ expect(canvas).not.toBeNull();
20
+ expect(canvas.getAttribute('role')).toBe('img');
21
+ expect(canvas.getAttribute('aria-label')).toBe('QR Code');
22
+ expect(canvas.width).toBe(128);
23
+ expect(canvas.height).toBe(128);
24
+ expect(el.value).toBe('');
25
+ expect(el.size).toBe(128);
26
+ expect(el.fill).toBe('#000000');
27
+ expect(el.background).toBe('#ffffff');
28
+ expect(el.radius).toBe(0);
29
+ expect(el.errorCorrection).toBe('M');
30
+ expect(el.logo).toBe('');
31
+ expect(el.logoSize).toBe(0);
32
+ expect(el.logoRadius).toBe(4);
33
+ expect(el.logoBackground).toBe('');
34
+ expect(el.logoPadding).toBe(4);
35
+ });
36
+
37
+ it('sets accessible label from value or custom label attribute', async () => {
38
+ const elWithValue = await renderQrCode(
39
+ '<dile-qr-code value="https://example.com"></dile-qr-code>'
40
+ );
41
+ const canvas1 = elWithValue.shadowRoot.querySelector('canvas');
42
+ expect(canvas1.getAttribute('aria-label')).toBe('https://example.com');
43
+
44
+ const elWithLabel = await renderQrCode(
45
+ '<dile-qr-code value="https://example.com" label="QR para pago"></dile-qr-code>'
46
+ );
47
+ const canvas2 = elWithLabel.shadowRoot.querySelector('canvas');
48
+ expect(canvas2.getAttribute('aria-label')).toBe('QR para pago');
49
+ });
50
+
51
+ it('renders pixels on canvas when value is provided', async () => {
52
+ const el = await renderQrCode(
53
+ '<dile-qr-code value="https://dile-components.com"></dile-qr-code>'
54
+ );
55
+ const canvas = el.shadowRoot.querySelector('canvas');
56
+ const ctx = canvas.getContext('2d');
57
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
58
+ const hasDrawnPixels = imageData.data.some((channel) => channel !== 0);
59
+ expect(hasDrawnPixels).toBe(true);
60
+ });
61
+
62
+ it('clears canvas when value is empty or cleared', async () => {
63
+ const el = await renderQrCode(
64
+ '<dile-qr-code value="https://example.com"></dile-qr-code>'
65
+ );
66
+ const canvas = el.shadowRoot.querySelector('canvas');
67
+ const ctx = canvas.getContext('2d');
68
+
69
+ // First check it has pixels
70
+ let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
71
+ expect(imageData.data.some((channel) => channel !== 0)).toBe(true);
72
+
73
+ // Empty the value
74
+ el.value = '';
75
+ await el.updateComplete;
76
+
77
+ imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
78
+ const hasDrawnPixels = imageData.data.some((channel) => channel !== 0);
79
+ expect(hasDrawnPixels).toBe(false);
80
+ });
81
+
82
+ it('updates canvas dimensions when size property changes', async () => {
83
+ const el = await renderQrCode(
84
+ '<dile-qr-code value="test" size="200"></dile-qr-code>'
85
+ );
86
+ const canvas = el.shadowRoot.querySelector('canvas');
87
+ expect(canvas.width).toBe(200);
88
+ expect(canvas.height).toBe(200);
89
+
90
+ el.size = 256;
91
+ await el.updateComplete;
92
+ expect(canvas.width).toBe(256);
93
+ expect(canvas.height).toBe(256);
94
+ });
95
+
96
+ it('updates canvas when fill, background, radius, or errorCorrection change', async () => {
97
+ const el = await renderQrCode(
98
+ '<dile-qr-code value="test" fill="#0000ff" background="#ffff00" radius="0.5" error-correction="H"></dile-qr-code>'
99
+ );
100
+ const canvas = el.shadowRoot.querySelector('canvas');
101
+ const ctx = canvas.getContext('2d');
102
+ let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
103
+ expect(imageData.data.some((channel) => channel !== 0)).toBe(true);
104
+
105
+ // Update fill and radius
106
+ el.fill = '#ff0000';
107
+ el.radius = 0.2;
108
+ el.errorCorrection = 'Q';
109
+ await el.updateComplete;
110
+
111
+ imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
112
+ expect(imageData.data.some((channel) => channel !== 0)).toBe(true);
113
+ });
114
+
115
+ it('handles transparent background', async () => {
116
+ const el = await renderQrCode(
117
+ '<dile-qr-code value="test" background="transparent"></dile-qr-code>'
118
+ );
119
+ const canvas = el.shadowRoot.querySelector('canvas');
120
+ expect(canvas).not.toBeNull();
121
+ const ctx = canvas.getContext('2d');
122
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
123
+ expect(imageData.data.some((channel) => channel !== 0)).toBe(true);
124
+ });
125
+
126
+ it('renders logo on top of QR code and dispatches dile-qr-code-logo-loaded', async () => {
127
+ const sampleLogo =
128
+ 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="20" height="20" fill="rgb(255,0,0)"/></svg>';
129
+
130
+ const el = await renderQrCode(
131
+ `<dile-qr-code value="https://dile-components.com" logo="${sampleLogo}" logo-size="24" logo-padding="2"></dile-qr-code>`
132
+ );
133
+
134
+ // Wait for the logo load event if not already fired
135
+ const canvas = el.shadowRoot.querySelector('canvas');
136
+ expect(canvas).not.toBeNull();
137
+
138
+ // Check center pixel in canvas to verify logo/badge was drawn
139
+ const ctx = canvas.getContext('2d');
140
+ const centerX = Math.floor(canvas.width / 2);
141
+ const centerY = Math.floor(canvas.height / 2);
142
+ const centerPixel = ctx.getImageData(centerX, centerY, 1, 1).data;
143
+ // Red channel from red svg rect
144
+ expect(centerPixel[0]).toBe(255);
145
+ });
146
+
147
+ it('handles logo load error gracefully and dispatches dile-qr-code-logo-error', async () => {
148
+ document.body.innerHTML = '';
149
+ const el = document.createElement('dile-qr-code');
150
+ el.value = 'https://dile-components.com';
151
+
152
+ const errorPromise = new Promise((resolve) => {
153
+ el.addEventListener('dile-qr-code-logo-error', resolve, { once: true });
154
+ });
155
+
156
+ el.logo = 'data:image/invalid;base64,broken';
157
+ document.body.appendChild(el);
158
+ await el.updateComplete;
159
+
160
+ const event = await errorPromise;
161
+ expect(event).toBeDefined();
162
+ expect(el.shadowRoot.querySelector('canvas')).not.toBeNull();
163
+ });
164
+ });
@@ -0,0 +1,234 @@
1
+ import { html, css, LitElement } from 'lit';
2
+ import QrCreator from 'qr-creator';
3
+
4
+ export class DileQrCode extends LitElement {
5
+ static properties = {
6
+ value: { type: String },
7
+ size: { type: Number },
8
+ fill: { type: String },
9
+ background: { type: String },
10
+ radius: { type: Number },
11
+ errorCorrection: { type: String, attribute: 'error-correction' },
12
+ label: { type: String },
13
+ logo: { type: String },
14
+ logoSize: { type: Number, attribute: 'logo-size' },
15
+ logoRadius: { type: Number, attribute: 'logo-radius' },
16
+ logoBackground: { type: String, attribute: 'logo-background' },
17
+ logoPadding: { type: Number, attribute: 'logo-padding' },
18
+ };
19
+
20
+ #currentRenderId = 0;
21
+
22
+ constructor() {
23
+ super();
24
+ this.value = '';
25
+ this.size = 128;
26
+ this.fill = '#000000';
27
+ this.background = '#ffffff';
28
+ this.radius = 0;
29
+ this.errorCorrection = 'M';
30
+ this.label = '';
31
+ this.logo = '';
32
+ this.logoSize = 0;
33
+ this.logoRadius = 4;
34
+ this.logoBackground = '';
35
+ this.logoPadding = 4;
36
+ }
37
+
38
+ static styles = css`
39
+ :host {
40
+ display: inline-block;
41
+ }
42
+ canvas {
43
+ display: block;
44
+ max-width: 100%;
45
+ height: auto;
46
+ }
47
+ `;
48
+
49
+ firstUpdated() {
50
+ this.#renderQrCode();
51
+ }
52
+
53
+ updated(changedProperties) {
54
+ if (
55
+ changedProperties.has('value') ||
56
+ changedProperties.has('size') ||
57
+ changedProperties.has('fill') ||
58
+ changedProperties.has('background') ||
59
+ changedProperties.has('radius') ||
60
+ changedProperties.has('errorCorrection') ||
61
+ changedProperties.has('logo') ||
62
+ changedProperties.has('logoSize') ||
63
+ changedProperties.has('logoRadius') ||
64
+ changedProperties.has('logoBackground') ||
65
+ changedProperties.has('logoPadding')
66
+ ) {
67
+ this.#renderQrCode();
68
+ }
69
+ }
70
+
71
+ #getCanvas() {
72
+ return this.shadowRoot ? this.shadowRoot.querySelector('canvas') : null;
73
+ }
74
+
75
+ #clearCanvas() {
76
+ const canvas = this.#getCanvas();
77
+ if (canvas) {
78
+ const ctx = canvas.getContext('2d');
79
+ if (ctx) {
80
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
81
+ }
82
+ }
83
+ }
84
+
85
+ #renderQrCode() {
86
+ const canvas = this.#getCanvas();
87
+ if (!canvas) {
88
+ return;
89
+ }
90
+
91
+ const renderId = ++this.#currentRenderId;
92
+ this.#clearCanvas();
93
+
94
+ if (!this.value || typeof this.value !== 'string' || this.value.trim() === '') {
95
+ return;
96
+ }
97
+
98
+ const hasLogo = Boolean(this.logo && typeof this.logo === 'string' && this.logo.trim() !== '');
99
+
100
+ let ecLevel;
101
+ if (hasLogo) {
102
+ ecLevel = this.errorCorrection === 'Q' ? 'Q' : 'H';
103
+ } else {
104
+ const validEcLevels = ['L', 'M', 'Q', 'H'];
105
+ ecLevel = validEcLevels.includes(this.errorCorrection)
106
+ ? this.errorCorrection
107
+ : 'M';
108
+ }
109
+
110
+ const numRadius = Number(this.radius);
111
+ const radius = Number.isNaN(numRadius)
112
+ ? 0
113
+ : Math.min(0.5, Math.max(0, numRadius));
114
+
115
+ const numSize = Number(this.size);
116
+ const size = Number.isNaN(numSize) || numSize <= 0 ? 128 : numSize;
117
+
118
+ const background =
119
+ this.background === 'transparent' || this.background === null
120
+ ? null
121
+ : this.background || '#ffffff';
122
+
123
+ try {
124
+ QrCreator.render(
125
+ {
126
+ text: this.value,
127
+ radius,
128
+ ecLevel,
129
+ fill: this.fill || '#000000',
130
+ background,
131
+ size,
132
+ },
133
+ canvas
134
+ );
135
+ } catch (error) {
136
+ console.warn('dile-qr-code: Error rendering QR code', error);
137
+ return;
138
+ }
139
+
140
+ if (hasLogo) {
141
+ this.#drawLogo(canvas, renderId);
142
+ }
143
+ }
144
+
145
+ #drawLogo(canvas, renderId) {
146
+ const img = new Image();
147
+ img.crossOrigin = 'anonymous';
148
+
149
+ img.onload = () => {
150
+ if (renderId !== this.#currentRenderId) {
151
+ return;
152
+ }
153
+ const currentCanvas = this.#getCanvas();
154
+ if (!currentCanvas || currentCanvas !== canvas) {
155
+ return;
156
+ }
157
+ const ctx = currentCanvas.getContext('2d');
158
+ if (!ctx) {
159
+ return;
160
+ }
161
+
162
+ const qrSize = currentCanvas.width;
163
+ const maxAllowedSize = Math.round(qrSize * 0.28);
164
+ const userLogoSize = Number(this.logoSize);
165
+ const targetSize =
166
+ userLogoSize > 0
167
+ ? Math.min(userLogoSize, maxAllowedSize)
168
+ : Math.round(qrSize * 0.22);
169
+
170
+ const numPadding = Number(this.logoPadding);
171
+ const padding = Number.isNaN(numPadding) || numPadding < 0 ? 4 : numPadding;
172
+
173
+ const badgeSize = targetSize + padding * 2;
174
+ const x = (qrSize - targetSize) / 2;
175
+ const y = (qrSize - targetSize) / 2;
176
+ const badgeX = x - padding;
177
+ const badgeY = y - padding;
178
+
179
+ const badgeBg =
180
+ this.logoBackground ||
181
+ (this.background === 'transparent'
182
+ ? '#ffffff'
183
+ : this.background || '#ffffff');
184
+
185
+ const numBadgeRadius = Number(this.logoRadius);
186
+ const badgeRadius =
187
+ Number.isNaN(numBadgeRadius) || numBadgeRadius < 0 ? 4 : numBadgeRadius;
188
+
189
+ ctx.save();
190
+ ctx.fillStyle = badgeBg;
191
+ ctx.beginPath();
192
+ if (typeof ctx.roundRect === 'function') {
193
+ ctx.roundRect(badgeX, badgeY, badgeSize, badgeSize, badgeRadius);
194
+ } else {
195
+ ctx.rect(badgeX, badgeY, badgeSize, badgeSize);
196
+ }
197
+ ctx.fill();
198
+
199
+ ctx.drawImage(img, x, y, targetSize, targetSize);
200
+ ctx.restore();
201
+
202
+ this.dispatchEvent(
203
+ new CustomEvent('dile-qr-code-logo-loaded', {
204
+ bubbles: true,
205
+ composed: true,
206
+ })
207
+ );
208
+ };
209
+
210
+ img.onerror = (err) => {
211
+ console.warn('dile-qr-code: Error loading logo image', this.logo, err);
212
+ this.dispatchEvent(
213
+ new CustomEvent('dile-qr-code-logo-error', {
214
+ bubbles: true,
215
+ composed: true,
216
+ detail: { error: err },
217
+ })
218
+ );
219
+ };
220
+
221
+ img.src = this.logo;
222
+ }
223
+
224
+ render() {
225
+ return html`
226
+ <canvas
227
+ role="img"
228
+ aria-label="${this.label || this.value || 'QR Code'}"
229
+ width="${this.size}"
230
+ height="${this.size}"
231
+ ></canvas>
232
+ `;
233
+ }
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/utils",
3
- "version": "3.1.14",
3
+ "version": "3.2.0",
4
4
  "description": "Utility Components of Diverse Uses Based on the Lit Library and Web Components Standard.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -21,14 +21,15 @@
21
21
  "homepage": "https://dile-components.com/",
22
22
  "dependencies": {
23
23
  "@dile/iconlib": "^2.0.1",
24
- "@dile/ui": "^3.12.4",
24
+ "@dile/ui": "^3.12.5",
25
25
  "@lion/ui": "^0.21.0",
26
26
  "linkify-string": "^4.1.3",
27
27
  "linkifyjs": "^4.1.3",
28
- "lit": "^2.7.0 || ^3.0.0"
28
+ "lit": "^2.7.0 || ^3.0.0",
29
+ "qr-creator": "^1.0.0"
29
30
  },
30
31
  "publishConfig": {
31
32
  "access": "public"
32
33
  },
33
- "gitHead": "29dbec2b9bc0a8f5613158409950ffd3456af179"
34
+ "gitHead": "638a0f521905e44ddc4cdad442947f1d1e7484a4"
34
35
  }