@jjlmoya/utils-hardware 1.18.0 → 1.19.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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  7. package/src/tests/tool_validation.test.ts +2 -2
  8. package/src/tool/batteryHealthEstimator/component.astro +3 -3
  9. package/src/tool/gamepadTest/component.astro +4 -3
  10. package/src/tool/gamepadVibrationTester/component.astro +3 -3
  11. package/src/tool/keyboardTest/component.astro +6 -1
  12. package/src/tool/mouseDoubleClickTest/bibliography.astro +14 -0
  13. package/src/tool/mouseDoubleClickTest/bibliography.ts +16 -0
  14. package/src/tool/mouseDoubleClickTest/component.astro +135 -0
  15. package/src/tool/mouseDoubleClickTest/entry.ts +29 -0
  16. package/src/tool/mouseDoubleClickTest/i18n/de.ts +274 -0
  17. package/src/tool/mouseDoubleClickTest/i18n/en.ts +274 -0
  18. package/src/tool/mouseDoubleClickTest/i18n/es.ts +274 -0
  19. package/src/tool/mouseDoubleClickTest/i18n/fr.ts +274 -0
  20. package/src/tool/mouseDoubleClickTest/i18n/id.ts +285 -0
  21. package/src/tool/mouseDoubleClickTest/i18n/it.ts +274 -0
  22. package/src/tool/mouseDoubleClickTest/i18n/ja.ts +274 -0
  23. package/src/tool/mouseDoubleClickTest/i18n/ko.ts +274 -0
  24. package/src/tool/mouseDoubleClickTest/i18n/nl.ts +274 -0
  25. package/src/tool/mouseDoubleClickTest/i18n/pl.ts +274 -0
  26. package/src/tool/mouseDoubleClickTest/i18n/pt.ts +274 -0
  27. package/src/tool/mouseDoubleClickTest/i18n/ru.ts +274 -0
  28. package/src/tool/mouseDoubleClickTest/i18n/sv.ts +274 -0
  29. package/src/tool/mouseDoubleClickTest/i18n/tr.ts +274 -0
  30. package/src/tool/mouseDoubleClickTest/i18n/zh.ts +274 -0
  31. package/src/tool/mouseDoubleClickTest/index.ts +9 -0
  32. package/src/tool/mouseDoubleClickTest/logic.ts +258 -0
  33. package/src/tool/mouseDoubleClickTest/mouse-double-click-test.css +488 -0
  34. package/src/tool/mouseDoubleClickTest/seo.astro +15 -0
  35. package/src/tool/mouseDoubleClickTest/ui.ts +26 -0
  36. package/src/tool/mousePollingTest/logic/RatonManager.ts +6 -6
  37. package/src/tool/toneGenerator/component.astro +7 -7
  38. package/src/tools.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-hardware",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -4,6 +4,7 @@ import { testTeclado } from '../tool/keyboardTest/index';
4
4
  import { testMando } from '../tool/gamepadTest/index';
5
5
  import { probadorVibracionMando } from '../tool/gamepadVibrationTester/index';
6
6
  import { testRaton } from '../tool/mousePollingTest/index';
7
+ import { mouseDoubleClickTest } from '../tool/mouseDoubleClickTest/index';
7
8
  import { estimadorSaludBateria } from '../tool/batteryHealthEstimator/index';
8
9
  import { toneGenerator } from '../tool/toneGenerator/index';
9
10
  import { refreshRateDetector } from '../tool/refreshRateDetector/index';
@@ -11,7 +12,7 @@ import { spectrumCanvas } from '../tool/colorAccuracyTest/index';
11
12
 
12
13
  export const hardwareCategory: HardwareCategoryEntry = {
13
14
  icon: 'mdi:memory',
14
- tools: [pixelesPantalla, testTeclado, testMando, probadorVibracionMando, testRaton, estimadorSaludBateria, toneGenerator, refreshRateDetector, spectrumCanvas],
15
+ tools: [pixelesPantalla, testTeclado, testMando, probadorVibracionMando, testRaton, mouseDoubleClickTest, estimadorSaludBateria, toneGenerator, refreshRateDetector, spectrumCanvas],
15
16
  i18n: {
16
17
  en: () => import('./i18n/en').then((m) => m.content),
17
18
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -10,6 +10,8 @@ export { testTeclado } from './tool/keyboardTest/entry';
10
10
  export type { TestTecladoLocaleContent } from './tool/keyboardTest/entry';
11
11
  export { testRaton } from './tool/mousePollingTest/entry';
12
12
  export type { TestRatonLocaleContent } from './tool/mousePollingTest/entry';
13
+ export { mouseDoubleClickTest } from './tool/mouseDoubleClickTest/entry';
14
+ export type { MouseDoubleClickTestLocaleContent } from './tool/mouseDoubleClickTest/entry';
13
15
  export { toneGenerator } from './tool/toneGenerator/entry';
14
16
  export type { ToneGeneratorLocaleContent } from './tool/toneGenerator/entry';
15
17
  export { refreshRateDetector } from './tool/refreshRateDetector/entry';
@@ -23,7 +25,8 @@ import { testMando } from './tool/gamepadTest/entry';
23
25
  import { probadorVibracionMando } from './tool/gamepadVibrationTester/entry';
24
26
  import { testTeclado } from './tool/keyboardTest/entry';
25
27
  import { testRaton } from './tool/mousePollingTest/entry';
28
+ import { mouseDoubleClickTest } from './tool/mouseDoubleClickTest/entry';
26
29
  import { toneGenerator } from './tool/toneGenerator/entry';
27
30
  import { refreshRateDetector } from './tool/refreshRateDetector/entry';
28
31
  import { spectrumCanvas } from './tool/colorAccuracyTest/entry';
29
- export const ALL_ENTRIES = [estimadorSaludBateria, pixelesPantalla, testMando, probadorVibracionMando, testTeclado, testRaton, toneGenerator, refreshRateDetector, spectrumCanvas];
32
+ export const ALL_ENTRIES = [estimadorSaludBateria, pixelesPantalla, testMando, probadorVibracionMando, testTeclado, testRaton, mouseDoubleClickTest, toneGenerator, refreshRateDetector, spectrumCanvas];
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export { TEST_TECLADO_TOOL } from './tool/keyboardTest/index';
22
22
  export { TEST_MANDO_TOOL } from './tool/gamepadTest/index';
23
23
  export { PROBADOR_VIBRACION_MANDO_TOOL } from './tool/gamepadVibrationTester/index';
24
24
  export { TEST_RATON_TOOL } from './tool/mousePollingTest/index';
25
+ export { MOUSE_DOUBLE_CLICK_TEST_TOOL } from './tool/mouseDoubleClickTest/index';
25
26
  export { ESTIMADOR_SALUD_BATERIA_TOOL } from './tool/batteryHealthEstimator/index';
26
27
  export { TONE_GENERATOR_TOOL } from './tool/toneGenerator/index';
27
28
  export { REFRESH_RATE_DETECTOR_TOOL } from './tool/refreshRateDetector/index';
@@ -21,8 +21,8 @@ describe('Locale Completeness Validation', () => {
21
21
  });
22
22
  });
23
23
 
24
- it('all 9 tools registered', () => {
25
- expect(ALL_TOOLS.length).toBe(9);
24
+ it('all 10 tools registered', () => {
25
+ expect(ALL_TOOLS.length).toBe(10);
26
26
  });
27
27
  });
28
28
 
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import type { SEOSection, ToolLocaleContent } from '../types';
6
+
7
+ const srcDir = join(process.cwd(), 'src');
8
+ const toolDir = join(srcDir, 'tool');
9
+ const geometryReads = [
10
+ 'offsetWidth',
11
+ 'offsetHeight',
12
+ 'offsetTop',
13
+ 'offsetLeft',
14
+ 'clientWidth',
15
+ 'clientHeight',
16
+ 'clientTop',
17
+ 'clientLeft',
18
+ 'scrollWidth',
19
+ 'scrollHeight',
20
+ 'scrollTop',
21
+ 'scrollLeft',
22
+ 'getBoundingClientRect',
23
+ 'getClientRects',
24
+ 'computedStyle',
25
+ 'getComputedStyle',
26
+ ];
27
+ const domWrites = [
28
+ '.style.',
29
+ '.classList.add',
30
+ '.classList.remove',
31
+ '.classList.toggle',
32
+ '.appendChild',
33
+ '.insertBefore',
34
+ '.prepend',
35
+ '.append',
36
+ '.remove',
37
+ '.innerHTML',
38
+ '.textContent',
39
+ '.setAttribute',
40
+ ];
41
+
42
+ function findFiles(dir: string, extensions: string[]): string[] {
43
+ const files: string[] = [];
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ const fullPath = join(dir, entry.name);
46
+ if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
47
+ else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
48
+ }
49
+ return files;
50
+ }
51
+
52
+ function relativePath(file: string): string {
53
+ return relative(process.cwd(), file).replace(/\\/g, '/');
54
+ }
55
+
56
+ function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
57
+ return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
58
+ }
59
+
60
+ function attrValue(tag: string, attr: string): string | null {
61
+ const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
62
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
63
+ }
64
+
65
+ function booleanAttr(tag: string, attr: string): boolean {
66
+ return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
67
+ }
68
+
69
+ function controlStartIndex(content: string, tag: RegExpMatchArray): number {
70
+ return tag.index ?? content.indexOf(tag[0]);
71
+ }
72
+
73
+ function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
74
+ const index = controlStartIndex(content, tag);
75
+ const before = content.slice(0, index);
76
+ const labelOpen = before.lastIndexOf('<label');
77
+ const labelClose = before.lastIndexOf('</label>');
78
+ const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
79
+ return labelOpen > labelClose && nextLabelClose !== -1;
80
+ }
81
+
82
+ function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
83
+ const source = tag[0];
84
+ if (attrValue(source, 'aria-label')) return true;
85
+ if (attrValue(source, 'aria-labelledby')) return true;
86
+ const id = attrValue(source, 'id');
87
+ if (id && hasExplicitLabel(content, id)) return true;
88
+ return hasWrappingLabel(content, tag);
89
+ }
90
+
91
+ function isVisuallyHiddenFileInput(tag: string): boolean {
92
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
93
+ const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
94
+ const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
95
+ return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
96
+ }
97
+
98
+ function isIgnoredInput(tag: string): boolean {
99
+ const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
100
+ return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
101
+ }
102
+
103
+ function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
104
+ return findFormControls(content, tagName)
105
+ .filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
106
+ .filter((tag) => !hasAccessibleName(content, tag))
107
+ .map((tag) => tag[0]);
108
+ }
109
+
110
+ function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
111
+ return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
112
+ }
113
+
114
+ function hasExplicitLabel(content: string, id: string): boolean {
115
+ const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+ return (
117
+ new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
118
+ || new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
119
+ );
120
+ }
121
+
122
+ function headingLevels(sections: SEOSection[]): number[] {
123
+ return sections
124
+ .filter((section) => section.type === 'title')
125
+ .map((section) => Number('level' in section ? section.level : 0))
126
+ .filter((level) => Number.isInteger(level) && level > 0);
127
+ }
128
+
129
+ function findHeadingLevelJumps(levels: number[]): string[] {
130
+ const failures: string[] = [];
131
+ levels.forEach((level, index) => {
132
+ const previous = index === 0 ? 1 : levels[index - 1];
133
+ if (previous && level > previous + 1) {
134
+ failures.push(`h${previous} -> h${level}`);
135
+ }
136
+ });
137
+ return failures;
138
+ }
139
+
140
+ function hasDomWriteBeforeGeometryRead(content: string): boolean {
141
+ const normalized = content.replace(/\s+/g, ' ');
142
+ return domWrites.some((write) => {
143
+ const writeIndex = normalized.indexOf(write);
144
+ if (writeIndex === -1) return false;
145
+ return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
146
+ });
147
+ }
148
+
149
+ describe('PageSpeed best-practice guards', () => {
150
+ const astroToolFiles = findFiles(toolDir, ['.astro']);
151
+ const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
152
+
153
+ astroToolFiles.forEach((file) => {
154
+ const displayPath = relativePath(file);
155
+
156
+ it(`${displayPath} labels every input with an explicit label`, () => {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const failures = controlFailures(content, 'input');
159
+
160
+ expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
161
+ });
162
+
163
+ it(`${displayPath} labels every select with an explicit label`, () => {
164
+ const content = readFileSync(file, 'utf-8');
165
+ const failures = controlFailures(content, 'select');
166
+
167
+ expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
168
+ });
169
+ });
170
+
171
+ ALL_TOOLS.forEach((tool) => {
172
+ Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
173
+ it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
174
+ if (!loader) return;
175
+ const content = (await loader()) as ToolLocaleContent;
176
+ const levels = headingLevels(content.seo);
177
+ const failures = findHeadingLevelJumps(levels);
178
+
179
+ expect(
180
+ failures,
181
+ `SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
182
+ ).toEqual([]);
183
+ });
184
+ });
185
+ });
186
+
187
+ scriptFiles.forEach((file) => {
188
+ const displayPath = relativePath(file);
189
+
190
+ it(`${displayPath} avoids static forced-reflow patterns`, () => {
191
+ const content = readFileSync(file, 'utf-8');
192
+ expect(
193
+ hasDomWriteBeforeGeometryRead(content),
194
+ `${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
195
+ ).toBe(false);
196
+ });
197
+ });
198
+ });
@@ -4,8 +4,8 @@ import { hardwareCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 9 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(9);
7
+ it('should have 10 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(10);
9
9
  });
10
10
 
11
11
  it('hardwareCategory should be defined', () => {
@@ -37,7 +37,7 @@ const S = JSON.stringify({
37
37
  <div class="bh-sliders">
38
38
  <div class="bh-slider-group">
39
39
  <div class="bh-slider-header">
40
- <label class="bh-slider-label">{t.voltageLabel}</label>
40
+ <label for="bh-v" class="bh-slider-label">{t.voltageLabel}</label>
41
41
  <span id="bh-v-val" class="bh-slider-val">3.70V</span>
42
42
  </div>
43
43
  <input type="range" id="bh-v" class="bh-slider" min="3.0" max="4.2" step="0.01" value="3.70" />
@@ -46,7 +46,7 @@ const S = JSON.stringify({
46
46
 
47
47
  <div class="bh-slider-group">
48
48
  <div class="bh-slider-header">
49
- <label class="bh-slider-label">{t.cyclesLabel}</label>
49
+ <label for="bh-c" class="bh-slider-label">{t.cyclesLabel}</label>
50
50
  <span id="bh-c-val" class="bh-slider-val">50</span>
51
51
  </div>
52
52
  <input type="range" id="bh-c" class="bh-slider" min="0" max="1000" step="1" value="50" />
@@ -54,7 +54,7 @@ const S = JSON.stringify({
54
54
 
55
55
  <div class="bh-slider-group">
56
56
  <div class="bh-slider-header">
57
- <label class="bh-slider-label">{t.tempLabel}</label>
57
+ <label for="bh-t" class="bh-slider-label">{t.tempLabel}</label>
58
58
  <span id="bh-t-val" class="bh-slider-val">25°C</span>
59
59
  </div>
60
60
  <input type="range" id="bh-t" class="bh-slider" min="0" max="60" step="1" value="25" />
@@ -341,11 +341,12 @@ const t = (ui ?? {}) as TestMandoUI;
341
341
  (targetBtn as HTMLElement).style.background = currentTarget.color;
342
342
  (targetBtn as HTMLElement).style.color = 'white';
343
343
  targetBtn.classList.remove('tm-target-hidden', 'tm-target-pop');
344
- void targetBtn.offsetWidth;
345
- targetBtn.classList.add('tm-target-pop');
346
344
  targetLabel.textContent = currentTarget.label;
347
345
  targetSub.textContent = currentTarget.type === 'axis' ? LABEL_MOVE : LABEL_PRESS;
348
- setTimeout(() => targetBtn.classList.remove('tm-target-pop'), 300);
346
+ requestAnimationFrame(() => {
347
+ targetBtn?.classList.add('tm-target-pop');
348
+ setTimeout(() => targetBtn?.classList.remove('tm-target-pop'), 300);
349
+ });
349
350
  }
350
351
  function startTimer() {
351
352
  timeLeft = 30;
@@ -113,7 +113,7 @@ const t = (ui ?? {}) as ProbadorVibracionMandoUI;
113
113
 
114
114
  <div class="pv-slider-group">
115
115
  <div class="pv-slider-header">
116
- <span>{t.customStrongLabel}</span>
116
+ <label for="pv-inp-str">{t.customStrongLabel}</label>
117
117
  <span id="pv-sl-str" class="pv-slider-val">100%</span>
118
118
  </div>
119
119
  <input type="range" class="pv-slider" id="pv-inp-str" min="0" max="1" step="0.05" value="1.0" />
@@ -121,7 +121,7 @@ const t = (ui ?? {}) as ProbadorVibracionMandoUI;
121
121
 
122
122
  <div class="pv-slider-group">
123
123
  <div class="pv-slider-header">
124
- <span>{t.customWeakLabel}</span>
124
+ <label for="pv-inp-wk">{t.customWeakLabel}</label>
125
125
  <span id="pv-sl-wk" class="pv-slider-val">100%</span>
126
126
  </div>
127
127
  <input type="range" class="pv-slider" id="pv-inp-wk" min="0" max="1" step="0.05" value="1.0" />
@@ -129,7 +129,7 @@ const t = (ui ?? {}) as ProbadorVibracionMandoUI;
129
129
 
130
130
  <div class="pv-slider-group">
131
131
  <div class="pv-slider-header">
132
- <span>{t.customDurationLabel}</span>
132
+ <label for="pv-inp-dur">{t.customDurationLabel}</label>
133
133
  <span id="pv-sl-dur" class="pv-slider-val">500ms</span>
134
134
  </div>
135
135
  <input type="range" class="pv-slider" id="pv-inp-dur" min="50" max="2500" step="50" value="500" />
@@ -187,6 +187,11 @@ const testedClass = 'tt-key-tested';
187
187
  const activeKeys = new Set<string>();
188
188
  let maxSimultaneous = 0;
189
189
 
190
+ function scrollLogToEnd(el: HTMLElement) {
191
+ void el.offsetWidth;
192
+ el.scrollLeft = el.scrollWidth;
193
+ }
194
+
190
195
  const keyMap = new Map<string, Element>();
191
196
  keys.forEach((k) => {
192
197
  const code = k.getAttribute('data-code');
@@ -199,7 +204,7 @@ const testedClass = 'tt-key-tested';
199
204
  div.textContent = `${msg}`;
200
205
  eventLog.appendChild(div);
201
206
  if (eventLog.children.length > 50) eventLog.firstElementChild?.remove();
202
- eventLog.scrollLeft = eventLog.scrollWidth;
207
+ scrollLogToEnd(eventLog);
203
208
  }
204
209
 
205
210
  function handleDown(e: KeyboardEvent) {
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import type { KnownLocale } from '../../types';
4
+ import { mouseDoubleClickTest } from './index';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const content = await mouseDoubleClickTest.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && content.bibliography.length > 0 && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'UI Events: click and dblclick event definitions',
6
+ url: 'https://www.w3.org/TR/uievents/',
7
+ },
8
+ {
9
+ name: 'MDN Web Docs: Element click event',
10
+ url: 'https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event',
11
+ },
12
+ {
13
+ name: 'MDN Web Docs: performance.now()',
14
+ url: 'https://developer.mozilla.org/en-US/docs/Web/API/Performance/now',
15
+ },
16
+ ];
@@ -0,0 +1,135 @@
1
+ ---
2
+ import type { KnownLocale } from '../../types';
3
+ import type { MouseDoubleClickTestUI } from './ui';
4
+ import { Icon } from 'astro-icon/components';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ ui?: Record<string, unknown>;
9
+ }
10
+
11
+ const { ui } = Astro.props;
12
+ const t = (ui ?? {}) as MouseDoubleClickTestUI;
13
+ ---
14
+
15
+ <div class="mdct-root" id="mdct-root" data-config={JSON.stringify({
16
+ statusIdle: t.statusIdle,
17
+ statusClean: t.statusClean,
18
+ statusWarning: t.statusWarning,
19
+ emptyLog: t.emptyLog,
20
+ leftButton: t.leftButton,
21
+ middleButton: t.middleButton,
22
+ rightButton: t.rightButton,
23
+ backButton: t.backButton,
24
+ forwardButton: t.forwardButton,
25
+ otherButton: t.otherButton,
26
+ thresholdUnit: t.thresholdUnit,
27
+ cleanEvent: t.cleanEvent,
28
+ suspiciousEvent: t.suspiciousEvent,
29
+ })}>
30
+ <section class="mdct-card">
31
+ <div class="mdct-stage">
32
+ <button id="mdct-target" class="mdct-target" type="button">
33
+ <span class="mdct-mouse" aria-hidden="true">
34
+ <span class="mdct-cable"></span>
35
+ <span class="mdct-button mdct-left" data-button="left" data-label={t.leftButton}></span>
36
+ <span class="mdct-button mdct-right" data-button="right" data-label={t.rightButton}></span>
37
+ <span class="mdct-button mdct-middle" data-button="middle" data-label={t.middleButton}></span>
38
+ <span class="mdct-button mdct-back" data-button="back" data-label={t.backButton}></span>
39
+ <span class="mdct-button mdct-forward" data-button="forward" data-label={t.forwardButton}></span>
40
+ <span class="mdct-palm"></span>
41
+ </span>
42
+ <span class="mdct-target-copy">
43
+ <span class="mdct-target-kicker">
44
+ <Icon name="mdi:mouse" class="mdct-target-icon" />
45
+ {t.badge}
46
+ </span>
47
+ <strong>{t.clickTarget}</strong>
48
+ <small>{t.clickTargetHint}</small>
49
+ </span>
50
+ </button>
51
+
52
+ <div class="mdct-dashboard">
53
+ <div class="mdct-readout">
54
+ <span>{t.healthScore}</span>
55
+ <strong id="mdct-score">100%</strong>
56
+ <small id="mdct-status" data-state="clean">{t.statusIdle}</small>
57
+ </div>
58
+
59
+ <div class="mdct-metrics">
60
+ <div class="mdct-metric mdct-metric-total">
61
+ <span>{t.totalClicks}</span>
62
+ <strong id="mdct-total">0</strong>
63
+ </div>
64
+ <div class="mdct-metric mdct-metric-alert">
65
+ <span>{t.suspiciousClicks}</span>
66
+ <strong id="mdct-suspicious">0</strong>
67
+ </div>
68
+ </div>
69
+
70
+ <label class="mdct-threshold">
71
+ <span>{t.thresholdLabel}</span>
72
+ <b><span id="mdct-threshold-value">80</span> {t.thresholdUnit}</b>
73
+ <input id="mdct-threshold" type="range" min="30" max="180" value="80" step="5" />
74
+ </label>
75
+
76
+ <div class="mdct-speed-row">
77
+ <div>
78
+ <span>{t.fastestGap}</span>
79
+ <strong id="mdct-fastest">--</strong>
80
+ </div>
81
+ <div>
82
+ <span>{t.lastGap}</span>
83
+ <strong id="mdct-last-gap">--</strong>
84
+ </div>
85
+ </div>
86
+
87
+ <div class="mdct-log-head">
88
+ <span>{t.logTitle}</span>
89
+ <button id="mdct-reset" class="mdct-reset" type="button">{t.reset}</button>
90
+ </div>
91
+ <ol id="mdct-log" class="mdct-log"></ol>
92
+ </div>
93
+ </div>
94
+ </section>
95
+ </div>
96
+
97
+ <script>
98
+ import { MouseDoubleClickTester } from './logic';
99
+
100
+ const root = document.getElementById('mdct-root');
101
+ const labels = JSON.parse(root?.dataset.config ?? '{}');
102
+ const buttons = root?.querySelectorAll<HTMLElement>('.mdct-button') ?? document.querySelectorAll<HTMLElement>('.mdct-button');
103
+
104
+ new MouseDoubleClickTester(
105
+ {
106
+ target: document.getElementById('mdct-target'),
107
+ total: document.getElementById('mdct-total'),
108
+ suspicious: document.getElementById('mdct-suspicious'),
109
+ fastest: document.getElementById('mdct-fastest'),
110
+ score: document.getElementById('mdct-score'),
111
+ status: document.getElementById('mdct-status'),
112
+ threshold: document.getElementById('mdct-threshold') as HTMLInputElement | null,
113
+ thresholdValue: document.getElementById('mdct-threshold-value'),
114
+ lastGap: document.getElementById('mdct-last-gap'),
115
+ log: document.getElementById('mdct-log'),
116
+ reset: document.getElementById('mdct-reset'),
117
+ buttons,
118
+ },
119
+ {
120
+ statusIdle: labels.statusIdle,
121
+ statusClean: labels.statusClean,
122
+ statusWarning: labels.statusWarning,
123
+ emptyLog: labels.emptyLog,
124
+ leftButton: labels.leftButton,
125
+ middleButton: labels.middleButton,
126
+ rightButton: labels.rightButton,
127
+ backButton: labels.backButton,
128
+ forwardButton: labels.forwardButton,
129
+ otherButton: labels.otherButton,
130
+ thresholdUnit: labels.thresholdUnit,
131
+ cleanEvent: labels.cleanEvent,
132
+ suspiciousEvent: labels.suspiciousEvent,
133
+ },
134
+ );
135
+ </script>
@@ -0,0 +1,29 @@
1
+ import type { HardwareToolEntry, ToolLocaleContent } from '../../types';
2
+
3
+ import type { MouseDoubleClickTestUI } from './ui';
4
+ export type MouseDoubleClickTestLocaleContent = ToolLocaleContent<MouseDoubleClickTestUI>;
5
+
6
+ export const mouseDoubleClickTest: HardwareToolEntry<MouseDoubleClickTestUI> = {
7
+ id: 'mouse-double-click-test',
8
+ icons: {
9
+ bg: 'mdi:mouse',
10
+ fg: 'mdi:gesture-tap-button',
11
+ },
12
+ i18n: {
13
+ de: () => import('./i18n/de').then((m) => m.content),
14
+ en: () => import('./i18n/en').then((m) => m.content),
15
+ es: () => import('./i18n/es').then((m) => m.content),
16
+ fr: () => import('./i18n/fr').then((m) => m.content),
17
+ id: () => import('./i18n/id').then((m) => m.content),
18
+ it: () => import('./i18n/it').then((m) => m.content),
19
+ ja: () => import('./i18n/ja').then((m) => m.content),
20
+ ko: () => import('./i18n/ko').then((m) => m.content),
21
+ nl: () => import('./i18n/nl').then((m) => m.content),
22
+ pl: () => import('./i18n/pl').then((m) => m.content),
23
+ pt: () => import('./i18n/pt').then((m) => m.content),
24
+ ru: () => import('./i18n/ru').then((m) => m.content),
25
+ sv: () => import('./i18n/sv').then((m) => m.content),
26
+ tr: () => import('./i18n/tr').then((m) => m.content),
27
+ zh: () => import('./i18n/zh').then((m) => m.content),
28
+ },
29
+ };