@jjlmoya/utils-nature 1.15.0 → 1.16.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.
- package/package.json +4 -2
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tool/rainHarvester/i18n/de.ts +1 -1
- package/src/tool/rainHarvester/i18n/en.ts +1 -1
- package/src/tool/rainHarvester/i18n/es.ts +1 -1
- package/src/tool/rainHarvester/i18n/fr.ts +1 -1
- package/src/tool/rainHarvester/i18n/id.ts +1 -1
- package/src/tool/rainHarvester/i18n/it.ts +1 -1
- package/src/tool/rainHarvester/i18n/ja.ts +1 -1
- package/src/tool/rainHarvester/i18n/ko.ts +1 -1
- package/src/tool/rainHarvester/i18n/nl.ts +1 -1
- package/src/tool/rainHarvester/i18n/pl.ts +1 -1
- package/src/tool/rainHarvester/i18n/pt.ts +1 -1
- package/src/tool/rainHarvester/i18n/ru.ts +1 -1
- package/src/tool/rainHarvester/i18n/sv.ts +1 -1
- package/src/tool/rainHarvester/i18n/tr.ts +1 -1
- package/src/tool/rainHarvester/i18n/zh.ts +1 -1
- package/src/tool/seedCalculator/component.astro +2 -2
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-nature",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./data": "./src/data.ts",
|
|
10
|
-
"./entries": "./src/entries.ts"
|
|
10
|
+
"./entries": "./src/entries.ts",
|
|
11
|
+
"./runtime/*": "./src/tool/*/index.ts",
|
|
12
|
+
"./category-seo": "./src/category/seo.astro"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"src",
|
|
@@ -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
|
+
});
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volumen = Fläche × Niederschlag × Abflussbeiwert × Filtereffizienz</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Area × Rainfall × Runoff Coefficient × Filter Efficiency</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volumen = Área × Precipitación × Coeficiente de Escorrentía × Eficiencia del Filtro</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Surface × Précipitations × Coefficient de Ruissellement × Efficacité du Filtre</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Luas × Curah Hujan × Koefisien Limpasan × Efisiensi Filter</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Area × Precipitazioni × Coefficiente di Deflusso × Efficienza Filtro</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">容量 = 面積 × 降水量 × 流出係数 × フィルター効率</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">용량 = 면적 × 강수량 × 유출 계수 × 필터 효율</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Oppervlak × Neerslag × Afvloeiingscoëfficiënt × Filterefficiëntie</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Objętość = Powierzchnia × Opady × Współczynnik Spływu × Wydajność Filtrów</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volume = Área × Pluviosidade × Coeficiente de Escoamento × Eficiência do Filtro</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Объем = Площадь × Осадки × Коэффициент стока × Эффективность фильтра</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Volym = Area × Nederbörd × Avrinningskoefficient × Filtereffektivitet</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">Hacim = Alan × Yağış × Akış Katsayısı × Filtre Verimliliği</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -126,7 +126,7 @@ export const content: RainHarvesterLocaleContent = {
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
type: 'paragraph',
|
|
129
|
-
html: '<code style="display:block;padding:1rem;background:var(
|
|
129
|
+
html: '<code style="display:block;padding:1rem;background:var(-bg-alt);border-radius:0.5rem;margin:1rem 0;font-family:monospace;">容量 = 面积 × 降雨量 × 径流系数 × 过滤效率</code>',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'list',
|
|
@@ -103,7 +103,7 @@ const cropNote: Record<string, string> = {
|
|
|
103
103
|
|
|
104
104
|
<div class="sc-params-card">
|
|
105
105
|
<div class="sc-slider-group">
|
|
106
|
-
<label class="sc-slider-label">
|
|
106
|
+
<label class="sc-slider-label" for="inPop">
|
|
107
107
|
{ui.labelPopulation}
|
|
108
108
|
<span class="sc-label-unit">{ui.unitSeedsHa}</span>
|
|
109
109
|
</label>
|
|
@@ -121,7 +121,7 @@ const cropNote: Record<string, string> = {
|
|
|
121
121
|
</div>
|
|
122
122
|
|
|
123
123
|
<div class="sc-slider-group">
|
|
124
|
-
<label class="sc-slider-label">
|
|
124
|
+
<label class="sc-slider-label" for="inRow">
|
|
125
125
|
{ui.labelRowWidth}
|
|
126
126
|
<span class="sc-label-unit">{ui.unitCm}</span>
|
|
127
127
|
</label>
|