@surea11y/core 1.6.0 → 1.8.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/CHANGELOG.md +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +243 -29
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +18061 -46194
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/earl.js +144 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
package/src/report.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* scorecard + searchable/filterable/paginated table, dark-mode CSS.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
const { NORMATIVE_STANDARDS, standardOfEntry } = require('./coverage/standards.js');
|
|
15
|
+
|
|
14
16
|
// Uses the dataviz skill's validated status palette, mapped 1:1 onto this
|
|
15
17
|
// engine's own 4 outcomes.
|
|
16
18
|
const STATUS = {
|
|
@@ -23,14 +25,78 @@ const STATUS = {
|
|
|
23
25
|
// fail first: a QA tester scanning a report wants violations up front,
|
|
24
26
|
// not passes.
|
|
25
27
|
const OUTCOME_ORDER = ['fail', 'cantTell', 'pass', 'notApplicable'];
|
|
26
|
-
const
|
|
27
|
-
fail: {
|
|
28
|
-
cantTell: {
|
|
29
|
-
pass: {
|
|
30
|
-
notApplicable: {
|
|
28
|
+
const OUTCOME_STATUS = {
|
|
29
|
+
fail: { status: 'serious', defaultOn: true },
|
|
30
|
+
cantTell: { status: 'warning', defaultOn: true },
|
|
31
|
+
pass: { status: 'good', defaultOn: false },
|
|
32
|
+
notApplicable: { status: 'neutral', defaultOn: false }
|
|
31
33
|
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
|
|
35
|
+
// Every word on the page comes from the engine's dictionaries (report_*
|
|
36
|
+
// keys), so a report reads in the language its scan resolved to and a new
|
|
37
|
+
// locale needs no change here. Loaded on first use: rendering a saved result
|
|
38
|
+
// should not cost a scan's worth of setup until a report is asked for.
|
|
39
|
+
let translator = null;
|
|
40
|
+
function getTranslator() {
|
|
41
|
+
if (!translator) translator = require('./core.js').__internal;
|
|
42
|
+
return translator;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The page speaks the locale the scan resolved to when the engine carries
|
|
46
|
+
// it. A dictionary the caller supplied at scan time is not in the result, so
|
|
47
|
+
// such a report falls back to English labels and marks the findings with
|
|
48
|
+
// their own language instead.
|
|
49
|
+
function createUi(engine) {
|
|
50
|
+
const core = getTranslator();
|
|
51
|
+
const locale = engine && engine.locale;
|
|
52
|
+
const contentLocale =
|
|
53
|
+
locale && typeof locale.resolved === 'string' && locale.resolved ? locale.resolved : 'en';
|
|
54
|
+
const uiLocale = core.resolveLocale(contentLocale).resolved;
|
|
55
|
+
|
|
56
|
+
const tr = (key, params) => core.translate(key, '', params || null, uiLocale);
|
|
57
|
+
|
|
58
|
+
let numberFormat;
|
|
59
|
+
try {
|
|
60
|
+
numberFormat = new Intl.NumberFormat(uiLocale);
|
|
61
|
+
} catch {
|
|
62
|
+
numberFormat = new Intl.NumberFormat('en-US');
|
|
63
|
+
}
|
|
64
|
+
const num = (n) => numberFormat.format(Number(n) || 0);
|
|
65
|
+
|
|
66
|
+
// Escapes the translated sentence, then puts the numbers back in bold.
|
|
67
|
+
// Dictionaries hold plain text; markup never goes through them.
|
|
68
|
+
function trStrong(key, params) {
|
|
69
|
+
const marked = {};
|
|
70
|
+
const values = [];
|
|
71
|
+
for (const name of Object.keys(params)) {
|
|
72
|
+
marked[name] = `\uE000${values.length}\uE000`;
|
|
73
|
+
values.push(params[name]);
|
|
74
|
+
}
|
|
75
|
+
return esc(tr(key, marked)).replace(
|
|
76
|
+
/\uE000(\d+)\uE000/g,
|
|
77
|
+
(m, i) => `<strong>${num(values[Number(i)])}</strong>`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const outcomeInfo = {};
|
|
82
|
+
for (const code of OUTCOME_ORDER) {
|
|
83
|
+
outcomeInfo[code] = {
|
|
84
|
+
label: tr(`report_outcome_${code}`),
|
|
85
|
+
defaultOn: OUTCOME_STATUS[code].defaultOn,
|
|
86
|
+
...STATUS[OUTCOME_STATUS[code].status]
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const severity = (s) => {
|
|
91
|
+
const label = typeof s === 'string' ? tr(`report_severity_${s}`) : '';
|
|
92
|
+
return label || String(s || '');
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// The English text a message key renders to, for telling a translated
|
|
96
|
+
// string from one that fell back to English.
|
|
97
|
+
const english = (key, params) => (key ? core.translate(key, '', params || null, 'en') : null);
|
|
98
|
+
|
|
99
|
+
return { tr, trStrong, num, outcomeInfo, severity, uiLocale, contentLocale, english };
|
|
34
100
|
}
|
|
35
101
|
|
|
36
102
|
function esc(s) {
|
|
@@ -84,24 +150,80 @@ function getCardOutcome(ruleResult) {
|
|
|
84
150
|
return hasCantTell ? 'cantTell' : ruleResult.outcome;
|
|
85
151
|
}
|
|
86
152
|
|
|
153
|
+
// Same test as src/sarif.js: other standards (EN 301 549, say) and WCAG's
|
|
154
|
+
// non-normative documents (`type: 'Understanding'`) share `requirement`
|
|
155
|
+
// with a Success Criterion, and chipping them as "WCAG" mislabels or repeats it.
|
|
156
|
+
function isWcagCriterion(m) {
|
|
157
|
+
return !!(m && m.requirement && (m.standard == null || m.standard === 'WCAG') && !m.type);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// What the scan was tested against, and any opt-in rules it added. A result
|
|
161
|
+
// from an engine older than the WCAG-version target carries none of these
|
|
162
|
+
// fields and gets no chips.
|
|
163
|
+
function renderTargetChips(engine, ui) {
|
|
164
|
+
if (!engine) return '';
|
|
165
|
+
const chips = [];
|
|
166
|
+
if (engine.wcagVersion)
|
|
167
|
+
chips.push(
|
|
168
|
+
`<div><b>WCAG ${esc(engine.wcagVersion)}</b>${esc(ui.tr('report_meta_target'))}</div>`
|
|
169
|
+
);
|
|
170
|
+
if (engine.profile)
|
|
171
|
+
chips.push(`<div><b>${esc(engine.profile)}</b>${esc(ui.tr('report_meta_profile'))}</div>`);
|
|
172
|
+
// Rules beyond the targeted standard ran (engineOptions.optInRules).
|
|
173
|
+
if (Array.isArray(engine.optInRules) && engine.optInRules.length)
|
|
174
|
+
chips.push(
|
|
175
|
+
`<div><b>${esc(engine.optInRules.join(', '))}</b>${esc(ui.tr('report_meta_optInRules'))}</div>`
|
|
176
|
+
);
|
|
177
|
+
return chips.join('\n ');
|
|
178
|
+
}
|
|
179
|
+
|
|
87
180
|
// Locale fallback is per-string and silent in the text itself, so a report
|
|
88
181
|
// generated in a locale the engine does not carry reads as a normal English
|
|
89
182
|
// one. A result from an older engine has no engine.locale and gets no chip.
|
|
90
|
-
function renderLocaleChip(engine) {
|
|
183
|
+
function renderLocaleChip(engine, ui) {
|
|
91
184
|
const locale = engine && engine.locale;
|
|
92
185
|
if (!locale || typeof locale.resolved !== 'string' || !locale.resolved) return '';
|
|
93
186
|
|
|
94
187
|
const fellBack = locale.requested !== locale.resolved;
|
|
95
|
-
const label =
|
|
188
|
+
const label = esc(
|
|
189
|
+
fellBack
|
|
190
|
+
? ui.tr('report_meta_localeRequested', { requested: locale.requested })
|
|
191
|
+
: ui.tr('report_meta_locale')
|
|
192
|
+
);
|
|
96
193
|
|
|
97
194
|
return `<div><b>${esc(locale.resolved)}</b>${label}</div>`;
|
|
98
195
|
}
|
|
99
196
|
|
|
197
|
+
// Rule titles, summaries and hints arrive in the scan's locale. When the
|
|
198
|
+
// page's own labels are in a different language (a caller-supplied
|
|
199
|
+
// dictionary the engine does not carry), those parts are marked with their
|
|
200
|
+
// language so a screen reader switches voice for them (WCAG 3.1.2).
|
|
201
|
+
const LANG_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
|
|
202
|
+
function contentLang(ui) {
|
|
203
|
+
const lang = ui.contentLocale;
|
|
204
|
+
if (!LANG_TAG.test(lang) || lang.toLowerCase() === ui.uiLocale.toLowerCase()) return '';
|
|
205
|
+
return lang;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The language one string is in: the scan's, unless the dictionary had no
|
|
209
|
+
// entry for its key and it came out as the English text, which the English
|
|
210
|
+
// page around it already covers. '' when no attribute is needed.
|
|
211
|
+
function textLang(ui, text, key, params) {
|
|
212
|
+
const lang = contentLang(ui);
|
|
213
|
+
if (!lang || !text) return '';
|
|
214
|
+
return key && ui.english(key, params) === text ? '' : lang;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function langAttr(lang) {
|
|
218
|
+
return lang ? ` lang="${esc(lang)}"` : '';
|
|
219
|
+
}
|
|
220
|
+
|
|
100
221
|
// One plain-language headline + one horizontal stacked bar + a legend with
|
|
101
222
|
// icon+label+count (status color is never the only signal) -- the first
|
|
102
223
|
// thing a reader sees; exhaustive detail lives in the collapsed
|
|
103
224
|
// "full technical data" section further down.
|
|
104
|
-
function renderHeroBar(byOutcome) {
|
|
225
|
+
function renderHeroBar(byOutcome, ui) {
|
|
226
|
+
const OUTCOME_INFO = ui.outcomeInfo;
|
|
105
227
|
const total = OUTCOME_ORDER.reduce((sum, c) => sum + byOutcome[c], 0) || 1;
|
|
106
228
|
const applicable = byOutcome.pass + byOutcome.fail + byOutcome.cantTell;
|
|
107
229
|
|
|
@@ -110,7 +232,7 @@ function renderHeroBar(byOutcome) {
|
|
|
110
232
|
const n = byOutcome[c];
|
|
111
233
|
const pct = (n / total) * 100;
|
|
112
234
|
const info = OUTCOME_INFO[c];
|
|
113
|
-
return `<div class="hero-bar-seg" style="width:${pct}%; background:${info.color}" title="${esc(info.label)}: ${
|
|
235
|
+
return `<div class="hero-bar-seg" style="width:${pct}%; background:${info.color}" title="${esc(info.label)}: ${ui.num(n)} (${pct.toFixed(1)}%)"></div>`;
|
|
114
236
|
})
|
|
115
237
|
.join('');
|
|
116
238
|
|
|
@@ -120,19 +242,26 @@ function renderHeroBar(byOutcome) {
|
|
|
120
242
|
return `<div class="hero-legend-item">
|
|
121
243
|
<span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
|
|
122
244
|
<span class="hero-legend-label">${esc(info.label)}</span>
|
|
123
|
-
<span class="hero-legend-count">${
|
|
245
|
+
<span class="hero-legend-count">${ui.num(n)} (${fmtPct(n, total)})</span>
|
|
124
246
|
</div>`;
|
|
125
247
|
}).join('\n');
|
|
126
248
|
|
|
127
249
|
let headline;
|
|
128
250
|
if (byOutcome.fail > 0) {
|
|
129
|
-
headline =
|
|
251
|
+
headline = ui.trStrong(
|
|
252
|
+
byOutcome.fail === 1 ? 'report_hero_failures_one' : 'report_hero_failures_other',
|
|
253
|
+
{ passed: byOutcome.pass, applicable, failed: byOutcome.fail }
|
|
254
|
+
);
|
|
130
255
|
} else if (byOutcome.cantTell > 0) {
|
|
131
|
-
headline =
|
|
256
|
+
headline = ui.trStrong('report_hero_review', {
|
|
257
|
+
passed: byOutcome.pass,
|
|
258
|
+
applicable,
|
|
259
|
+
review: byOutcome.cantTell
|
|
260
|
+
});
|
|
132
261
|
} else if (applicable > 0) {
|
|
133
|
-
headline =
|
|
262
|
+
headline = ui.trStrong('report_hero_allPassed', { applicable });
|
|
134
263
|
} else {
|
|
135
|
-
headline =
|
|
264
|
+
headline = esc(ui.tr('report_hero_none'));
|
|
136
265
|
}
|
|
137
266
|
|
|
138
267
|
return `<div class="hero">
|
|
@@ -142,13 +271,14 @@ function renderHeroBar(byOutcome) {
|
|
|
142
271
|
</div>`;
|
|
143
272
|
}
|
|
144
273
|
|
|
145
|
-
function renderScorecard(byOutcome) {
|
|
274
|
+
function renderScorecard(byOutcome, ui) {
|
|
275
|
+
const OUTCOME_INFO = ui.outcomeInfo;
|
|
146
276
|
const total = OUTCOME_ORDER.reduce((sum, c) => sum + byOutcome[c], 0);
|
|
147
277
|
const tiles = OUTCOME_ORDER.map((c) => {
|
|
148
278
|
const n = byOutcome[c];
|
|
149
279
|
const info = OUTCOME_INFO[c];
|
|
150
280
|
return `<div class="tile" style="border-color:${info.color}; background:${info.bg}">
|
|
151
|
-
<div class="tile-num" style="color:${info.color}">${
|
|
281
|
+
<div class="tile-num" style="color:${info.color}">${ui.num(n)}</div>
|
|
152
282
|
<div class="tile-pct">${fmtPct(n, total)}</div>
|
|
153
283
|
<div class="tile-label">${esc(info.label)}</div>
|
|
154
284
|
</div>`;
|
|
@@ -160,9 +290,10 @@ function renderScorecard(byOutcome) {
|
|
|
160
290
|
// entry per Success Criterion, docs/WCAG_CONFORMANCE.md), not an invented
|
|
161
291
|
// grouping. Grouped by conformance level (A / AA / AAA) since that's the
|
|
162
292
|
// axis a compliance-minded reader actually cares about.
|
|
163
|
-
function renderWcagRollup(rulesResults) {
|
|
293
|
+
function renderWcagRollup(rulesResults, ui) {
|
|
294
|
+
const OUTCOME_INFO = ui.outcomeInfo;
|
|
164
295
|
if (!Array.isArray(rulesResults) || !rulesResults.length) {
|
|
165
|
-
return
|
|
296
|
+
return `<p class="note">${esc(ui.tr('report_rollup_none'))}</p>`;
|
|
166
297
|
}
|
|
167
298
|
|
|
168
299
|
const byLevel = { A: [], AA: [], AAA: [], other: [] };
|
|
@@ -189,11 +320,34 @@ function renderWcagRollup(rulesResults) {
|
|
|
189
320
|
const metrics = (rule.data && rule.data.details && rule.data.details.metrics) || {};
|
|
190
321
|
const checksIds = (rule.data && rule.data.details && rule.data.details.checksIds) || [];
|
|
191
322
|
const chip = `<span class="chip" style="background:${info.bg};color:${info.color}">${esc(info.label)}</span>`;
|
|
192
|
-
const scLabel = mapping
|
|
193
|
-
|
|
323
|
+
const scLabel = mapping
|
|
324
|
+
? `WCAG ${esc(mapping.requirement)}`
|
|
325
|
+
: esc(ui.tr('report_rollup_unmapped'));
|
|
326
|
+
// One line per registered standard the row carries, in registry
|
|
327
|
+
// order. A requirement numbered the same in two versions (as EN 301
|
|
328
|
+
// 549 clauses are) is listed once.
|
|
329
|
+
const ruleMappings = (rule.meta && rule.meta.normativeMappings) || [];
|
|
330
|
+
const enLabel = NORMATIVE_STANDARDS.map((standard) => {
|
|
331
|
+
const requirements = Array.from(
|
|
332
|
+
new Set(
|
|
333
|
+
ruleMappings
|
|
334
|
+
.filter((m) => standardOfEntry(m) === standard)
|
|
335
|
+
.map((m) => m.requirement)
|
|
336
|
+
)
|
|
337
|
+
);
|
|
338
|
+
return requirements.length
|
|
339
|
+
? `<br><span class="note">${esc(standard.standard)} ${requirements.map(esc).join(', ')}</span>`
|
|
340
|
+
: '';
|
|
341
|
+
}).join('');
|
|
342
|
+
const metricsLabel = ui.tr('report_rollup_breakdown', {
|
|
343
|
+
pass: ui.num(metrics.passCount),
|
|
344
|
+
fail: ui.num(metrics.failCount),
|
|
345
|
+
review: ui.num(metrics.cantTellCount),
|
|
346
|
+
na: ui.num(metrics.notApplicableCount)
|
|
347
|
+
});
|
|
194
348
|
return `<tr>
|
|
195
|
-
<td class="sc-cell">${scLabel}</td>
|
|
196
|
-
<td>${esc(rule.title || rule.ruleId)}</td>
|
|
349
|
+
<td class="sc-cell">${scLabel}${enLabel}</td>
|
|
350
|
+
<td${langAttr(textLang(ui, rule.title, rule.i18n && rule.i18n.titleKey))}>${esc(rule.title || rule.ruleId)}</td>
|
|
197
351
|
<td>${chip}</td>
|
|
198
352
|
<td class="note">${esc(metricsLabel)}</td>
|
|
199
353
|
<td class="note">${esc(checksIds.join(', '))}</td>
|
|
@@ -201,9 +355,13 @@ function renderWcagRollup(rulesResults) {
|
|
|
201
355
|
})
|
|
202
356
|
.join('\n');
|
|
203
357
|
|
|
204
|
-
|
|
358
|
+
const levelHeading =
|
|
359
|
+
level === 'other'
|
|
360
|
+
? ui.tr('report_rollup_levelUnmapped')
|
|
361
|
+
: ui.tr('report_rollup_level', { level });
|
|
362
|
+
return `<h3 class="wcag-level-heading">${esc(levelHeading)}</h3>
|
|
205
363
|
<table class="wcag-table">
|
|
206
|
-
<thead><tr><th
|
|
364
|
+
<thead><tr><th>${esc(ui.tr('report_rollup_col_sc'))}</th><th>${esc(ui.tr('report_rollup_col_requirement'))}</th><th>${esc(ui.tr('report_col_outcome'))}</th><th>${esc(ui.tr('report_rollup_col_breakdown'))}</th><th>${esc(ui.tr('report_rollup_col_contributing'))}</th></tr></thead>
|
|
207
365
|
<tbody>${rows}</tbody>
|
|
208
366
|
</table>`;
|
|
209
367
|
})
|
|
@@ -212,8 +370,56 @@ function renderWcagRollup(rulesResults) {
|
|
|
212
370
|
return sections;
|
|
213
371
|
}
|
|
214
372
|
|
|
373
|
+
// A registered standard's own rollups (one per requirement, say), present only
|
|
374
|
+
// when the scan produced them. `standard.report` supplies the note above the
|
|
375
|
+
// table and the language of the titles when it is not the scan's.
|
|
376
|
+
function renderStandardRollup(standard, results, ui) {
|
|
377
|
+
const OUTCOME_INFO = ui.outcomeInfo;
|
|
378
|
+
const report = standard.report || {};
|
|
379
|
+
const titleLang = report.titleLang ? ` lang="${esc(report.titleLang)}"` : '';
|
|
380
|
+
const byCriterion = (r) =>
|
|
381
|
+
String((r.data && r.data.details && r.data.details.criterion) || r.ruleId || '');
|
|
382
|
+
const rows = results
|
|
383
|
+
.slice()
|
|
384
|
+
.sort((a, b) => byCriterion(a).localeCompare(byCriterion(b), undefined, { numeric: true }))
|
|
385
|
+
.map((rule) => {
|
|
386
|
+
const info = OUTCOME_INFO[rule.outcome] || OUTCOME_INFO.notApplicable;
|
|
387
|
+
const details = (rule.data && rule.data.details) || {};
|
|
388
|
+
const metrics = details.metrics || {};
|
|
389
|
+
const checksIds = details.checksIds || [];
|
|
390
|
+
const tests = Array.from(
|
|
391
|
+
new Set(
|
|
392
|
+
((rule.meta && rule.meta.normativeMappings) || [])
|
|
393
|
+
.filter((m) => standardOfEntry(m) === standard)
|
|
394
|
+
.map((m) => m.requirement)
|
|
395
|
+
)
|
|
396
|
+
);
|
|
397
|
+
const chip = `<span class="chip" style="background:${info.bg};color:${info.color}">${esc(info.label)}</span>`;
|
|
398
|
+
const metricsLabel = ui.tr('report_rollup_breakdown', {
|
|
399
|
+
pass: ui.num(metrics.passCount),
|
|
400
|
+
fail: ui.num(metrics.failCount),
|
|
401
|
+
review: ui.num(metrics.cantTellCount),
|
|
402
|
+
na: ui.num(metrics.notApplicableCount)
|
|
403
|
+
});
|
|
404
|
+
return `<tr>
|
|
405
|
+
<td class="sc-cell">${esc(standard.standard)} ${esc(byCriterion(rule))}${tests.length ? `<br><span class="note">${tests.map(esc).join(', ')}</span>` : ''}</td>
|
|
406
|
+
<td${titleLang}>${esc(rule.title || rule.ruleId)}</td>
|
|
407
|
+
<td>${chip}</td>
|
|
408
|
+
<td class="note">${esc(metricsLabel)}</td>
|
|
409
|
+
<td class="note">${esc(checksIds.join(', '))}</td>
|
|
410
|
+
</tr>`;
|
|
411
|
+
})
|
|
412
|
+
.join('\n');
|
|
413
|
+
const note = report.noteKey ? `<p class="note">${esc(ui.tr(report.noteKey))}</p>\n ` : '';
|
|
414
|
+
return `${note}<table class="wcag-table">
|
|
415
|
+
<thead><tr><th>${esc(ui.tr('report_standardRollup_col_criterion'))}</th><th>${esc(ui.tr('report_rollup_col_requirement'))}</th><th>${esc(ui.tr('report_col_outcome'))}</th><th>${esc(ui.tr('report_rollup_col_breakdown'))}</th><th>${esc(ui.tr('report_rollup_col_contributing'))}</th></tr></thead>
|
|
416
|
+
<tbody>${rows}</tbody>
|
|
417
|
+
</table>`;
|
|
418
|
+
}
|
|
419
|
+
|
|
215
420
|
// Collapse internal whitespace/newlines and cap length for card display --
|
|
216
|
-
// the findings table below shows the untruncated
|
|
421
|
+
// the findings table below shows the untruncated selector and summary. The
|
|
422
|
+
// hint is left whole: it is the fix advice, and the table does not repeat it.
|
|
217
423
|
const CARD_SNIPPET_MAX = 220;
|
|
218
424
|
function truncateForCard(s) {
|
|
219
425
|
const collapsed = String(s || '')
|
|
@@ -228,7 +434,8 @@ function truncateForCard(s) {
|
|
|
228
434
|
// the same underlying issue is one thing worth a person's attention, not N.
|
|
229
435
|
const MAX_CARDS = 24;
|
|
230
436
|
|
|
231
|
-
function renderCards(checksResults) {
|
|
437
|
+
function renderCards(checksResults, ui) {
|
|
438
|
+
const OUTCOME_INFO = ui.outcomeInfo;
|
|
232
439
|
const withIssues = checksResults.filter(
|
|
233
440
|
(r) =>
|
|
234
441
|
Array.isArray(r.occurrences) &&
|
|
@@ -238,7 +445,7 @@ function renderCards(checksResults) {
|
|
|
238
445
|
})
|
|
239
446
|
);
|
|
240
447
|
if (!withIssues.length) {
|
|
241
|
-
return
|
|
448
|
+
return `<p class="note">${esc(ui.tr('report_cards_none'))}</p>`;
|
|
242
449
|
}
|
|
243
450
|
|
|
244
451
|
const sorted = withIssues.slice().sort((a, b) => {
|
|
@@ -261,7 +468,9 @@ function renderCards(checksResults) {
|
|
|
261
468
|
const representative =
|
|
262
469
|
r.occurrences.find((occ) => getOccurrenceOutcome(r, occ) === cardOutcome) ||
|
|
263
470
|
r.occurrences[0];
|
|
471
|
+
const occI18n = representative.i18n || {};
|
|
264
472
|
const wcagChips = ((r.meta && r.meta.normativeMappings) || [])
|
|
473
|
+
.filter(isWcagCriterion)
|
|
265
474
|
.map(
|
|
266
475
|
(m) =>
|
|
267
476
|
`<span class="chip" style="background:${STATUS.neutral.bg};color:${STATUS.neutral.color}">WCAG ${esc(m.requirement)}</span>`
|
|
@@ -270,19 +479,19 @@ function renderCards(checksResults) {
|
|
|
270
479
|
const hasMixedOutcomes = occurrenceCounts.fail > 0 && occurrenceCounts.cantTell > 0;
|
|
271
480
|
const countLabel =
|
|
272
481
|
r.occurrences.length > 1
|
|
273
|
-
? ` <span class="card-count">× ${r.occurrences.length
|
|
482
|
+
? ` <span class="card-count">× ${ui.num(r.occurrences.length)}${hasMixedOutcomes ? ` ${esc(ui.tr('report_card_mixed', { fail: ui.num(occurrenceCounts.fail), review: ui.num(occurrenceCounts.cantTell) }))}` : ''}</span>`
|
|
274
483
|
: '';
|
|
275
484
|
|
|
276
485
|
return `<div class="card">
|
|
277
486
|
<div class="card-head">
|
|
278
487
|
<span class="hero-legend-swatch" style="background:${info.color}">${info.icon}</span>
|
|
279
|
-
<span class="card-title"><strong>${esc(r.ruleId)}</strong>
|
|
488
|
+
<span class="card-title"><strong>${esc(r.ruleId)}</strong> ${esc(ui.tr('report_card_status', { outcome: info.label, severity: ui.severity(r.severity) }))}${countLabel}</span>
|
|
280
489
|
</div>
|
|
281
490
|
<div class="card-body">
|
|
282
491
|
<div class="card-meta">${wcagChips}</div>
|
|
283
|
-
<div class="card-selector"><span class="card-selector-label"
|
|
284
|
-
<div class="card-snippet">${
|
|
285
|
-
${r.occurrences.length > 1 ? `<p class="card-note"
|
|
492
|
+
<div class="card-selector"><span class="card-selector-label">${esc(ui.tr('report_card_selector'))}</span> <code>${esc(representative.selector ? truncateForCard(representative.selector) : ui.tr('report_card_noSelector'))}</code></div>
|
|
493
|
+
<div class="card-snippet">${snippetPart(ui, truncateForCard(representative.summary), representative.summary, occI18n.summaryKey, occI18n.params)}${representative.hint ? ` — ${snippetPart(ui, representative.hint, representative.hint, occI18n.hintKey, occI18n.params)}` : ''}</div>
|
|
494
|
+
${r.occurrences.length > 1 ? `<p class="card-note">${esc(ui.tr('report_card_representative', { count: ui.num(r.occurrences.length) }))}</p>` : ''}
|
|
286
495
|
</div>
|
|
287
496
|
</div>`;
|
|
288
497
|
})
|
|
@@ -290,16 +499,23 @@ function renderCards(checksResults) {
|
|
|
290
499
|
|
|
291
500
|
const overflow =
|
|
292
501
|
sorted.length > MAX_CARDS
|
|
293
|
-
? `<p class="note"
|
|
502
|
+
? `<p class="note">${esc(ui.tr('report_cards_overflow', { shown: ui.num(MAX_CARDS), total: ui.num(sorted.length) }))}</p>`
|
|
294
503
|
: '';
|
|
295
504
|
|
|
296
505
|
return `<div class="cards">${cards}</div>${overflow}`;
|
|
297
506
|
}
|
|
298
507
|
|
|
508
|
+
// A summary or hint for a card, wrapped in its language when that is not the
|
|
509
|
+
// page's. `full` is the untruncated text the key rendered to.
|
|
510
|
+
function snippetPart(ui, shown, full, key, params) {
|
|
511
|
+
const lang = textLang(ui, full, key, params);
|
|
512
|
+
return lang ? `<span lang="${esc(lang)}">${esc(shown)}</span>` : esc(shown);
|
|
513
|
+
}
|
|
514
|
+
|
|
299
515
|
// Flatten every occurrence across every rule into one row per occurrence --
|
|
300
516
|
// the searchable/filterable/paginated detail view. Rules with zero
|
|
301
517
|
// occurrences (pass/notApplicable) have nothing to show at this granularity.
|
|
302
|
-
function flattenOccurrences(checksResults) {
|
|
518
|
+
function flattenOccurrences(checksResults, ui) {
|
|
303
519
|
const rows = [];
|
|
304
520
|
for (const r of checksResults) {
|
|
305
521
|
if (!Array.isArray(r.occurrences)) continue;
|
|
@@ -309,9 +525,16 @@ function flattenOccurrences(checksResults) {
|
|
|
309
525
|
ruleId: r.ruleId,
|
|
310
526
|
outcome: occurrenceOutcome || r.outcome,
|
|
311
527
|
severity: r.severity,
|
|
528
|
+
severityLabel: ui.severity(r.severity),
|
|
312
529
|
selector: occ.selector || '',
|
|
313
530
|
html: occ.html || '',
|
|
314
531
|
summary: occ.summary || '',
|
|
532
|
+
summaryLang: textLang(
|
|
533
|
+
ui,
|
|
534
|
+
occ.summary,
|
|
535
|
+
occ.i18n && occ.i18n.summaryKey,
|
|
536
|
+
occ.i18n && occ.i18n.params
|
|
537
|
+
),
|
|
315
538
|
hint: occ.hint || ''
|
|
316
539
|
});
|
|
317
540
|
}
|
|
@@ -321,19 +544,45 @@ function flattenOccurrences(checksResults) {
|
|
|
321
544
|
|
|
322
545
|
function renderHtmlReport(result, options = {}) {
|
|
323
546
|
const checksResults = Array.isArray(result && result.checksResults) ? result.checksResults : [];
|
|
324
|
-
const
|
|
547
|
+
const allRollups = Array.isArray(result && result.rulesResults) ? result.rulesResults : [];
|
|
548
|
+
// A rollup a registered standard defines for itself carries that standard's
|
|
549
|
+
// name in meta.standard and gets a section of its own after the WCAG one.
|
|
550
|
+
const ownStandard = (r) =>
|
|
551
|
+
(r && r.meta && NORMATIVE_STANDARDS.find((s) => s.standard === r.meta.standard)) || null;
|
|
552
|
+
const rulesResults = allRollups.filter((r) => !ownStandard(r));
|
|
553
|
+
const standardRollups = NORMATIVE_STANDARDS.map((standard) => ({
|
|
554
|
+
standard,
|
|
555
|
+
results: allRollups.filter((r) => ownStandard(r) === standard)
|
|
556
|
+
})).filter((s) => s.results.length);
|
|
325
557
|
const byOutcome = countByOutcome(checksResults);
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
558
|
+
const engine = result && result.engine;
|
|
559
|
+
const ui = createUi(engine);
|
|
560
|
+
const OUTCOME_INFO = ui.outcomeInfo;
|
|
561
|
+
let generatedAtLabel;
|
|
562
|
+
try {
|
|
563
|
+
generatedAtLabel = new Date().toLocaleString(ui.uiLocale, {
|
|
564
|
+
dateStyle: 'medium',
|
|
565
|
+
timeStyle: 'medium'
|
|
566
|
+
});
|
|
567
|
+
} catch {
|
|
568
|
+
generatedAtLabel = new Date().toLocaleString('en-US', {
|
|
569
|
+
dateStyle: 'medium',
|
|
570
|
+
timeStyle: 'medium'
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
const title = (options && options.title) || ui.tr('report_title_default');
|
|
331
574
|
|
|
332
575
|
const defaultOnList = OUTCOME_ORDER.filter((c) => OUTCOME_INFO[c].defaultOn);
|
|
333
|
-
const rows = flattenOccurrences(checksResults);
|
|
576
|
+
const rows = flattenOccurrences(checksResults, ui);
|
|
577
|
+
const pageLang = LANG_TAG.test(ui.uiLocale) ? ui.uiLocale : 'en';
|
|
578
|
+
// Strings the in-page script needs; it cannot call the translator.
|
|
579
|
+
const clientText = {
|
|
580
|
+
none: ui.tr('report_pager_none'),
|
|
581
|
+
info: ui.tr('report_pager_info')
|
|
582
|
+
};
|
|
334
583
|
|
|
335
584
|
return `<!doctype html>
|
|
336
|
-
<html lang="
|
|
585
|
+
<html lang="${esc(pageLang)}">
|
|
337
586
|
<head>
|
|
338
587
|
<meta charset="utf-8">
|
|
339
588
|
<title>${esc(title)} — ${esc(generatedAtLabel)}</title>
|
|
@@ -416,53 +665,61 @@ function renderHtmlReport(result, options = {}) {
|
|
|
416
665
|
|
|
417
666
|
<header>
|
|
418
667
|
<h1>${esc(title)}</h1>
|
|
419
|
-
<div class="sub">${esc(result && result.url ? result.url :
|
|
668
|
+
<div class="sub">${esc(result && result.url ? result.url : ui.tr('report_noUrl'))} — ${esc(ui.tr('report_generated', { date: generatedAtLabel }))}</div>
|
|
420
669
|
</header>
|
|
421
670
|
|
|
422
671
|
<div class="meta-bar">
|
|
423
|
-
<div><b>${checksResults.length}</b
|
|
424
|
-
<div><b>${rows.length}</b
|
|
425
|
-
<div><b>${esc((
|
|
426
|
-
<div><b>${esc((
|
|
427
|
-
${
|
|
672
|
+
<div><b>${ui.num(checksResults.length)}</b>${esc(ui.tr('report_meta_rulesRun'))}</div>
|
|
673
|
+
<div><b>${ui.num(rows.length)}</b>${esc(ui.tr('report_meta_totalOccurrences'))}</div>
|
|
674
|
+
<div><b>${esc((engine && engine.tag) || '?')}</b>${esc(ui.tr('report_meta_engine'))}</div>
|
|
675
|
+
<div><b>${esc((engine && engine.schemaVersion) || '?')}</b>${esc(ui.tr('report_meta_schemaVersion'))}</div>
|
|
676
|
+
${renderTargetChips(engine, ui)}
|
|
677
|
+
${renderLocaleChip(engine, ui)}
|
|
428
678
|
</div>
|
|
429
679
|
|
|
430
680
|
<main>
|
|
431
|
-
${renderHeroBar(byOutcome)}
|
|
432
|
-
|
|
433
|
-
<h2
|
|
434
|
-
${renderCards(checksResults)}
|
|
435
|
-
|
|
436
|
-
<h2
|
|
437
|
-
${renderWcagRollup(rulesResults)}
|
|
438
|
-
|
|
681
|
+
${renderHeroBar(byOutcome, ui)}
|
|
682
|
+
|
|
683
|
+
<h2>${esc(ui.tr('report_heading_worthReviewing'))}</h2>
|
|
684
|
+
${renderCards(checksResults, ui)}
|
|
685
|
+
|
|
686
|
+
<h2>${esc(ui.tr('report_heading_wcagRollup'))}</h2>
|
|
687
|
+
${renderWcagRollup(rulesResults, ui)}
|
|
688
|
+
${standardRollups
|
|
689
|
+
.map(
|
|
690
|
+
({ standard, results }) => `
|
|
691
|
+
<h2>${esc(ui.tr('report_heading_standardRollup', { standard: standard.standard }))}</h2>
|
|
692
|
+
${renderStandardRollup(standard, results, ui)}
|
|
693
|
+
`
|
|
694
|
+
)
|
|
695
|
+
.join('')}
|
|
439
696
|
<details class="tech-details">
|
|
440
|
-
<summary
|
|
697
|
+
<summary>${esc(ui.tr('report_techDetails'))}</summary>
|
|
441
698
|
<div class="tech-body">
|
|
442
|
-
<h2
|
|
443
|
-
${renderScorecard(byOutcome)}
|
|
699
|
+
<h2>${esc(ui.tr('report_heading_scorecard'))}</h2>
|
|
700
|
+
${renderScorecard(byOutcome, ui)}
|
|
444
701
|
|
|
445
|
-
<h2
|
|
702
|
+
<h2>${esc(ui.tr('report_heading_occurrences'))}</h2>
|
|
446
703
|
<div class="filters" id="filters"></div>
|
|
447
|
-
<input type="search" id="search"
|
|
704
|
+
<input type="search" id="search" aria-label="${esc(ui.tr('report_search_placeholder'))}" placeholder="${esc(ui.tr('report_search_placeholder'))}" style="margin-bottom:10px; padding:6px 10px; border:1px solid #ccc; border-radius:5px; font-size:13px; width:100%; max-width:480px;">
|
|
448
705
|
<div style="overflow-x:auto;">
|
|
449
706
|
<table class="findings-table" id="findings-table">
|
|
450
707
|
<thead>
|
|
451
708
|
<tr>
|
|
452
|
-
<th
|
|
453
|
-
<th
|
|
454
|
-
<th
|
|
455
|
-
<th
|
|
456
|
-
<th
|
|
709
|
+
<th>${esc(ui.tr('report_col_rule'))}</th>
|
|
710
|
+
<th>${esc(ui.tr('report_col_outcome'))}</th>
|
|
711
|
+
<th>${esc(ui.tr('report_col_severity'))}</th>
|
|
712
|
+
<th>${esc(ui.tr('report_col_selector'))}</th>
|
|
713
|
+
<th>${esc(ui.tr('report_col_summary'))}</th>
|
|
457
714
|
</tr>
|
|
458
715
|
</thead>
|
|
459
716
|
<tbody id="findings-body"></tbody>
|
|
460
717
|
</table>
|
|
461
718
|
</div>
|
|
462
719
|
<div class="pager">
|
|
463
|
-
<button id="prev-page">←
|
|
720
|
+
<button id="prev-page">← ${esc(ui.tr('report_pager_prev'))}</button>
|
|
464
721
|
<span id="page-info"></span>
|
|
465
|
-
<button id="next-page"
|
|
722
|
+
<button id="next-page">${esc(ui.tr('report_pager_next'))} →</button>
|
|
466
723
|
</div>
|
|
467
724
|
</div>
|
|
468
725
|
</details>
|
|
@@ -477,6 +734,13 @@ function renderHtmlReport(result, options = {}) {
|
|
|
477
734
|
var rows = JSON.parse(document.getElementById('report-data').textContent);
|
|
478
735
|
|
|
479
736
|
var PAGE_SIZE = 100;
|
|
737
|
+
var TEXT = ${jsonForScript(clientText)};
|
|
738
|
+
var numberFormat = new Intl.NumberFormat(${jsonForScript(ui.uiLocale)});
|
|
739
|
+
function fill(template, values) {
|
|
740
|
+
return template.replace(/\\{\\{(\\w+)\\}\\}/g, function (m, k) {
|
|
741
|
+
return Object.prototype.hasOwnProperty.call(values, k) ? String(values[k]) : m;
|
|
742
|
+
});
|
|
743
|
+
}
|
|
480
744
|
var page = 0;
|
|
481
745
|
var active = {};
|
|
482
746
|
DEFAULT_ON.forEach(function (c) { active[c] = true; });
|
|
@@ -531,15 +795,20 @@ function renderHtmlReport(result, options = {}) {
|
|
|
531
795
|
return '<tr>' +
|
|
532
796
|
'<td class="snippet">' + esc(r.ruleId) + '</td>' +
|
|
533
797
|
'<td>' + chip + '</td>' +
|
|
534
|
-
'<td>' + esc(r.
|
|
798
|
+
'<td>' + esc(r.severityLabel) + '</td>' +
|
|
535
799
|
'<td class="snippet" title="' + esc(r.html) + '">' + esc(r.selector) + '</td>' +
|
|
536
|
-
'<td class="snippet">' + esc(r.summary) + '</td>' +
|
|
800
|
+
'<td class="snippet"' + (r.summaryLang ? ' lang="' + esc(r.summaryLang) + '"' : '') + '>' + esc(r.summary) + '</td>' +
|
|
537
801
|
'</tr>';
|
|
538
802
|
}).join('');
|
|
539
803
|
|
|
540
804
|
pageInfoEl.textContent = filtered.length === 0
|
|
541
|
-
?
|
|
542
|
-
: (
|
|
805
|
+
? TEXT.none
|
|
806
|
+
: fill(TEXT.info, {
|
|
807
|
+
page: numberFormat.format(page + 1),
|
|
808
|
+
pages: numberFormat.format(totalPages),
|
|
809
|
+
matching: numberFormat.format(filtered.length),
|
|
810
|
+
total: numberFormat.format(rows.length)
|
|
811
|
+
});
|
|
543
812
|
prevBtn.disabled = page <= 0;
|
|
544
813
|
nextBtn.disabled = page >= totalPages - 1;
|
|
545
814
|
}
|