affora 0.1.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 +16 -0
- package/CHECKS.md +40 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/agent.md +70 -0
- package/checks/cli.mjs +44 -0
- package/checks/substrate.mjs +269 -0
- package/cli/affora.mjs +131 -0
- package/design.md +113 -0
- package/package.json +71 -0
- package/registry.json +83 -0
- package/rules/antd.js +173 -0
- package/rules/generic.js +299 -0
- package/rules/hidden-radio.js +94 -0
- package/rules/magento.js +325 -0
- package/rules/mui.js +230 -0
- package/rules/shadcn-baseui.js +169 -0
- package/rules/shadcn-radix.js +289 -0
- package/src/components/combobox.tsx +279 -0
- package/src/components/datatable.tsx +454 -0
- package/src/components/dialog.tsx +548 -0
- package/src/components/productcard.tsx +551 -0
- package/src/components/settingsform.tsx +583 -0
- package/src/patterns/confirmundo.tsx +134 -0
- package/src/patterns/errremedy.tsx +155 -0
- package/src/patterns/flowform.tsx +179 -0
- package/src/patterns/gatedaction.tsx +157 -0
- package/src/patterns/persistentfeedback.tsx +106 -0
- package/src/primitives/actionbutton.tsx +64 -0
- package/src/primitives/textfield.tsx +57 -0
- package/src/tokens/themes.css +1068 -0
package/rules/generic.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Generic rules — behavioural detection only, no site-specific class names.
|
|
2
|
+
// These fire on any page whose widgets follow common structure or ARIA, which is
|
|
3
|
+
// what makes them safe defaults for a compiled site whose class names are build
|
|
4
|
+
// output. Site files (magento.js, mui.js, …) layer on top and may override.
|
|
5
|
+
({
|
|
6
|
+
// A choice control: native <select>, or a custom widget claiming the role.
|
|
7
|
+
select: {
|
|
8
|
+
// Native selects always; custom choice controls only when probing them is
|
|
9
|
+
// SAFE. Probing means clicking, and a documentation site's search opens a
|
|
10
|
+
// full-screen modal that unmounts the page — on mui.com that deleted all 85
|
|
11
|
+
// sidebar links from the DOM, and parity then reported 130 severed navigation
|
|
12
|
+
// paths for a site that was working perfectly. A probe must not be able to
|
|
13
|
+
// destroy the page it is reading.
|
|
14
|
+
find: () => {
|
|
15
|
+
const safe = (e) => {
|
|
16
|
+
// A portalled listbox BELONGS to a trigger; it is not a control of its own.
|
|
17
|
+
// Replacing it separately minted a second select from the popup and left a
|
|
18
|
+
// label pointing at the popup's id after the popup closed.
|
|
19
|
+
if (e.getAttribute('role') === 'listbox') {
|
|
20
|
+
const id = e.getAttribute('id');
|
|
21
|
+
if (id && document.querySelector(
|
|
22
|
+
'[aria-controls~="' + id + '"],[aria-owns~="' + id + '"]')) return false;
|
|
23
|
+
}
|
|
24
|
+
// A LINK is not a choice control, whatever ARIA it carries, and probing
|
|
25
|
+
// means clicking: a fixture link marked aria-haspopup=listbox took the
|
|
26
|
+
// probe straight off the page.
|
|
27
|
+
if (e.tagName === 'A' || e.hasAttribute('href')) return false;
|
|
28
|
+
if (e.getAttribute('aria-haspopup') === 'dialog') return false;
|
|
29
|
+
if (e.matches('input[type="search"]')) return false;
|
|
30
|
+
if (e.closest('[role="search"],form[role="search"]')) return false;
|
|
31
|
+
const hint = ((e.getAttribute('aria-label') || '') + ' ' +
|
|
32
|
+
(e.getAttribute('placeholder') || '') + ' ' +
|
|
33
|
+
(e.getAttribute('name') || '')).toLowerCase();
|
|
34
|
+
return !/search|find/.test(hint);
|
|
35
|
+
};
|
|
36
|
+
// aria-haspopup="listbox" is the ARIA signature React Aria and Base UI emit;
|
|
37
|
+
// neither puts role="combobox" on the trigger, so detecting it here is the
|
|
38
|
+
// difference between covering two more libraries and writing two more files.
|
|
39
|
+
return [
|
|
40
|
+
...document.querySelectorAll('select'),
|
|
41
|
+
...[...document.querySelectorAll(
|
|
42
|
+
'[role="combobox"],[role="listbox"],[aria-haspopup="listbox"]')].filter(safe),
|
|
43
|
+
];
|
|
44
|
+
},
|
|
45
|
+
extract: (el) => {
|
|
46
|
+
let opts = [];
|
|
47
|
+
if (el.tagName === 'SELECT') {
|
|
48
|
+
// Carry the GROUP each option belongs to. Flattening an <optgroup> away
|
|
49
|
+
// deletes the label that says what the group is — 'Movies', 'Europe' —
|
|
50
|
+
// which is a fact the page states and, on a grouped select, often the only
|
|
51
|
+
// thing distinguishing two identically-named options.
|
|
52
|
+
opts = [...el.options].map(o => ({
|
|
53
|
+
value: o.value, label: rsRendered(o),
|
|
54
|
+
group: o.parentElement && o.parentElement.tagName === 'OPTGROUP'
|
|
55
|
+
? (o.parentElement.getAttribute('label') || '') : '',
|
|
56
|
+
}));
|
|
57
|
+
} else {
|
|
58
|
+
const owned = el.getAttribute('aria-controls') || el.getAttribute('aria-owns');
|
|
59
|
+
// A combobox that names its listbox is the easy case. Many do not — Mantine
|
|
60
|
+
// ships none — so fall back to the listbox that BELONGS to this control:
|
|
61
|
+
// walk up a few levels and take the first one found in that subtree. Bounded
|
|
62
|
+
// deliberately, because searching the document would attach a stray
|
|
63
|
+
// listbox's options to whichever control happened to be probed first.
|
|
64
|
+
let box = owned ? document.getElementById(owned) : null;
|
|
65
|
+
if (!box) {
|
|
66
|
+
for (let n = el, i = 0; n && i < 4; n = n.parentElement, i++) {
|
|
67
|
+
const found = n.querySelector && n.querySelector('[role="listbox"]');
|
|
68
|
+
if (found) { box = found; break; }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!box) box = el;
|
|
72
|
+
// A listbox groups its options with [role=group], and the group's name is
|
|
73
|
+
// a fact the page states — 'Movies', 'Europe' — often the only thing
|
|
74
|
+
// telling two identically-named options apart. Read it from wherever ARIA
|
|
75
|
+
// puts it: an explicit label, a referenced element, or the group's own
|
|
76
|
+
// heading text (the child that is not an option).
|
|
77
|
+
const groupName = (o) => {
|
|
78
|
+
const g = o.closest('[role="group"],optgroup');
|
|
79
|
+
if (!g) return '';
|
|
80
|
+
const byRef = g.getAttribute('aria-labelledby');
|
|
81
|
+
return (g.getAttribute('label') || g.getAttribute('aria-label')
|
|
82
|
+
|| (byRef ? rsRendered(document.getElementById(byRef)) : '')
|
|
83
|
+
|| rsRendered([...g.children].find(c => !c.matches('[role="option"],option,li')))
|
|
84
|
+
|| '').trim();
|
|
85
|
+
};
|
|
86
|
+
opts = [...(box ? box.querySelectorAll('[role="option"],li,option') : [])]
|
|
87
|
+
.map(o => ({ value: o.getAttribute('data-value') || rsRendered(o),
|
|
88
|
+
label: rsRendered(o), group: groupName(o) }))
|
|
89
|
+
.filter(o => o.label);
|
|
90
|
+
}
|
|
91
|
+
if (!opts.length) {
|
|
92
|
+
// PROBE. A popup's options do not exist until it opens, so a passive read
|
|
93
|
+
// can only ever skip — which is why every library that portals its options
|
|
94
|
+
// needed its own file. Open it with the page's own interaction, let the
|
|
95
|
+
// options mount, and read what the page itself rendered. Bounded at three
|
|
96
|
+
// attempts, after which the instance is skipped honestly; native selects
|
|
97
|
+
// never reach here, and a control that yields nothing is left alone.
|
|
98
|
+
if (el.tagName !== 'SELECT') {
|
|
99
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
100
|
+
if (tries < 3) {
|
|
101
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
102
|
+
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
103
|
+
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
|
104
|
+
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
105
|
+
try { el.click(); } catch (e) {}
|
|
106
|
+
return 'retry';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null; // options unreachable: skip
|
|
110
|
+
}
|
|
111
|
+
// If the page ALREADY labels this control, do not add a second one. The
|
|
112
|
+
// replacement keeps the original id, so the existing label[for] still
|
|
113
|
+
// resolves to it — rendering our own produced a visible "Sort By Sort By"
|
|
114
|
+
// and put a duplicate of every rewritten control's label into the page.
|
|
115
|
+
// Close whatever the probe opened: leaving a popup up would let the next
|
|
116
|
+
// control's probe harvest THIS one's options.
|
|
117
|
+
if (el.getAttribute('data-rs-probe'))
|
|
118
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
119
|
+
const id0 = el.getAttribute('id') || '';
|
|
120
|
+
const external = id0 && document.querySelector('label[for="' + CSS.escape(id0) + '"]');
|
|
121
|
+
// What the CLOSED control displays is a fact too — a placeholder ("Select
|
|
122
|
+
// movie") or the current choice. It is not one of the options, so listing
|
|
123
|
+
// only the options deleted it.
|
|
124
|
+
return { label: external ? '' : rsLabel(el), opts,
|
|
125
|
+
shown: el.tagName === 'SELECT' ? '' : rsRendered(el),
|
|
126
|
+
value: el.tagName === 'SELECT' ? el.value : '',
|
|
127
|
+
name: el.getAttribute('name') || '',
|
|
128
|
+
id: el.getAttribute('id') || '', // keep the id: label[for] must survive
|
|
129
|
+
orig: el.tagName === 'SELECT' ? el : null,
|
|
130
|
+
trigger: el.tagName === 'SELECT' ? null : el };
|
|
131
|
+
},
|
|
132
|
+
render: (d) => {
|
|
133
|
+
const id = d.id || ('rs-' + (++window.__rsSeq));
|
|
134
|
+
const one = (o) =>
|
|
135
|
+
`<option value="${rsEsc(o.value)}"${o.value === d.value ? ' selected' : ''}>${rsEsc(o.label)}</option>`;
|
|
136
|
+
// Rebuild the groups rather than listing their members loose.
|
|
137
|
+
let opts = '', open = null;
|
|
138
|
+
d.opts.forEach(o => {
|
|
139
|
+
const g = o.group || '';
|
|
140
|
+
if (g !== open) {
|
|
141
|
+
if (open) opts += '</optgroup>';
|
|
142
|
+
if (g) opts += `<optgroup label="${rsEsc(g)}">`;
|
|
143
|
+
open = g;
|
|
144
|
+
}
|
|
145
|
+
opts += one(o);
|
|
146
|
+
});
|
|
147
|
+
if (open) opts += '</optgroup>';
|
|
148
|
+
// Lead with whatever the control was showing when closed, unless that text
|
|
149
|
+
// is already one of the options.
|
|
150
|
+
if (d.shown && !d.opts.some(o => o.label === d.shown))
|
|
151
|
+
opts = `<option value="" selected>${rsEsc(d.shown)}</option>` + opts;
|
|
152
|
+
return `<div class="rs rs-field">
|
|
153
|
+
${d.label ? `<label class="rs-label" for="${id}">${rsEsc(d.label)}</label>` : ''}
|
|
154
|
+
<select class="rs-select" id="${id}"${d.name ? ` name="${rsEsc(d.name)}"` : ''}>${opts}</select>
|
|
155
|
+
</div>`;
|
|
156
|
+
},
|
|
157
|
+
// SHADOW-DRIVE a native select. Rebuilding one produces an element with the
|
|
158
|
+
// same options and NO BEHAVIOUR: the host's change handler stayed with the
|
|
159
|
+
// original, so choosing an option did nothing. Parity could not see it — it
|
|
160
|
+
// compares which affordances exist, not what using them does — and the sort
|
|
161
|
+
// task failed on the rewritten arm while passing on the baseline.
|
|
162
|
+
// The original rides along hidden and receives the real event.
|
|
163
|
+
after: (d, holder) => {
|
|
164
|
+
// A CUSTOM trigger gets the same treatment as a native select, for the same
|
|
165
|
+
// reason: the application holds the state and a rebuilt control has no
|
|
166
|
+
// connection to it, so the replacement would display a choice nothing
|
|
167
|
+
// receives. The original rides along hidden and is driven the way a person
|
|
168
|
+
// drives it — opened, then the matching option clicked.
|
|
169
|
+
if (d.trigger) {
|
|
170
|
+
d.trigger.style.display = 'none';
|
|
171
|
+
d.trigger.setAttribute('data-rs-done', '');
|
|
172
|
+
holder.appendChild(d.trigger);
|
|
173
|
+
const shownSel = holder.querySelector('select.rs-select');
|
|
174
|
+
if (shownSel) shownSel.addEventListener('change', () => {
|
|
175
|
+
const want = shownSel.selectedOptions[0] && shownSel.selectedOptions[0].textContent.trim();
|
|
176
|
+
if (!want) return;
|
|
177
|
+
d.trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
178
|
+
d.trigger.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
|
179
|
+
try { d.trigger.click(); } catch (e) {}
|
|
180
|
+
setTimeout(() => {
|
|
181
|
+
const opt = [...document.querySelectorAll('[role="option"]')]
|
|
182
|
+
.find(o => rsRendered(o) === want);
|
|
183
|
+
if (opt) opt.click();
|
|
184
|
+
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
185
|
+
}, 140);
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!d.orig) return;
|
|
190
|
+
d.orig.style.display = 'none';
|
|
191
|
+
d.orig.setAttribute('data-rs-done', '');
|
|
192
|
+
holder.appendChild(d.orig);
|
|
193
|
+
const shown = holder.querySelector('select.rs-select');
|
|
194
|
+
if (!shown) return;
|
|
195
|
+
shown.addEventListener('change', () => {
|
|
196
|
+
d.orig.value = shown.value;
|
|
197
|
+
d.orig.dispatchEvent(new Event('input', { bubbles: true }));
|
|
198
|
+
d.orig.dispatchEvent(new Event('change', { bubbles: true }));
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
// A tab strip: ARIA tablists, plus the commoner un-ARIA'd form — a cluster of
|
|
204
|
+
// sibling buttons whose handlers name sibling panels. Replaced by plain
|
|
205
|
+
// sections, every panel's content present; the panels MOVE (removes/after), so
|
|
206
|
+
// nothing is duplicated and nothing is lost.
|
|
207
|
+
tabs: {
|
|
208
|
+
// flattening: the tab/section controls exist to reveal content this
|
|
209
|
+
// output reveals structurally, so they are subsumed, not lost
|
|
210
|
+
subsumes: true,
|
|
211
|
+
find: () => {
|
|
212
|
+
// A NAVIGATION switcher is not a content tab strip. Magento's mobile nav is
|
|
213
|
+
// a two-tab switch between menu and account links; flattening it revealed a
|
|
214
|
+
// mobile-only region on a desktop page and, with it, five pre-existing
|
|
215
|
+
// colour-contrast failures that were previously not rendered. Revealing
|
|
216
|
+
// content is the point of this rule; revealing a different VIEWPORT's
|
|
217
|
+
// chrome is not.
|
|
218
|
+
const contentTabs = (el) => !el.closest('nav,[role="navigation"]')
|
|
219
|
+
&& !/nav-sections|switcher|viewport/i.test(String(el.className || ''));
|
|
220
|
+
const out = new Set([...document.querySelectorAll('[role="tablist"]')].filter(contentTabs));
|
|
221
|
+
document.querySelectorAll('button,[role="button"],a').forEach(b => {
|
|
222
|
+
const h = (b.getAttribute('onclick') || '') + (b.className || '');
|
|
223
|
+
if (!/tab|panel|switch/i.test(h)) return;
|
|
224
|
+
const g = b.parentElement;
|
|
225
|
+
if (!g) return;
|
|
226
|
+
const peers = [...g.children].filter(c => /^(BUTTON|A)$/.test(c.tagName) &&
|
|
227
|
+
/tab|panel|switch/i.test((c.getAttribute('onclick') || '') + (c.className || '')));
|
|
228
|
+
if (peers.length >= 2 && contentTabs(g)) out.add(g);
|
|
229
|
+
});
|
|
230
|
+
return [...out];
|
|
231
|
+
},
|
|
232
|
+
extract: (el) => {
|
|
233
|
+
const tabs = [], controls = [];
|
|
234
|
+
[...el.querySelectorAll('[role="tab"],button,[role="button"],a')].forEach(t => {
|
|
235
|
+
const title = rsRendered(t);
|
|
236
|
+
if (!title) return;
|
|
237
|
+
let panel = null;
|
|
238
|
+
const owned = t.getAttribute('aria-controls');
|
|
239
|
+
if (owned) panel = document.getElementById(owned);
|
|
240
|
+
if (!panel) {
|
|
241
|
+
const m = (t.getAttribute('onclick') || '').match(/['"]([\w -]+)['"]/);
|
|
242
|
+
if (m) panel = document.getElementById('tab-' + m[1]) || document.getElementById(m[1]);
|
|
243
|
+
}
|
|
244
|
+
if (panel) { tabs.push({ title, html: panel.innerHTML, panel }); controls.push(t); }
|
|
245
|
+
});
|
|
246
|
+
return tabs.length >= 2 ? { tabs, controls } : null;
|
|
247
|
+
},
|
|
248
|
+
anchor: (el, d) => d.controls[0],
|
|
249
|
+
render: (d) => `<div class="rs rs-sections">` + d.tabs.map(t =>
|
|
250
|
+
`<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
|
|
251
|
+
<div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
|
|
252
|
+
removes: (d) => [...d.controls.slice(1), ...d.tabs.map(t => t.panel)],
|
|
253
|
+
after: (d) => {
|
|
254
|
+
d.controls.slice(1).forEach(c => c.remove());
|
|
255
|
+
d.tabs.forEach(t => t.panel && t.panel.remove());
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
// An icon-only control — the commonest unnamed target anywhere. Its name exists
|
|
260
|
+
// in title/alt/aria-label; the swap puts that name into the DOM as visible text.
|
|
261
|
+
// No name anywhere means naming it would invent a fact: skip.
|
|
262
|
+
icon_control: {
|
|
263
|
+
find: () => [...document.querySelectorAll('button,[role="button"],a')]
|
|
264
|
+
.filter(b => !(b.textContent || '').trim() && !b.closest('.rs')),
|
|
265
|
+
extract: (el) => {
|
|
266
|
+
const name = el.getAttribute('aria-label') || el.getAttribute('title')
|
|
267
|
+
|| (el.querySelector('img') || {}).alt
|
|
268
|
+
|| rsRendered(el.querySelector('svg title')) || '';
|
|
269
|
+
if (!name.trim()) return null;
|
|
270
|
+
return { name: name.trim(), href: el.getAttribute('href') || '',
|
|
271
|
+
tag: el.tagName === 'A' ? 'a' : 'button', inner: el.innerHTML, src: el };
|
|
272
|
+
},
|
|
273
|
+
render: (d) => d.tag === 'a'
|
|
274
|
+
? `<a class="rs rs-iconbtn" href="${rsEsc(d.href)}">${d.inner}<span class="rs-iconbtn-name">${rsEsc(d.name)}</span></a>`
|
|
275
|
+
: `<button type="button" class="rs rs-iconbtn">${d.inner}<span class="rs-iconbtn-name">${rsEsc(d.name)}</span></button>`,
|
|
276
|
+
// SHADOW-DRIVE the original. A rebuilt button carries the name and none of
|
|
277
|
+
// the behaviour: where the page's handler is a framework listener rather
|
|
278
|
+
// than an href, the replacement is inert, and the arm that was supposed to
|
|
279
|
+
// gain a name silently loses a control. Found on a data grid whose next-page
|
|
280
|
+
// button stopped paging; effect parity could not see it, because it only
|
|
281
|
+
// exercises controls BOTH arms name and a replaced trigger is not one.
|
|
282
|
+
after: (d, holder) => {
|
|
283
|
+
const src = d.src;
|
|
284
|
+
if (!src) return;
|
|
285
|
+
src.style.display = 'none';
|
|
286
|
+
src.setAttribute('data-rs-shadow', '');
|
|
287
|
+
holder.appendChild(src);
|
|
288
|
+
const shown = holder.querySelector('.rs-iconbtn');
|
|
289
|
+
if (!shown || shown === src) return;
|
|
290
|
+
shown.addEventListener('click', (e) => {
|
|
291
|
+
if (d.tag === 'a' && d.href) return; // a link needs no forwarding
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
src.style.display = '';
|
|
294
|
+
src.click();
|
|
295
|
+
src.style.display = 'none';
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// A radio group whose inputs are hidden by CSS and whose labels are styled as
|
|
2
|
+
// buttons — the commonest way a designer turns a choice into a row of chips.
|
|
3
|
+
//
|
|
4
|
+
// The pattern is behavioural, not site-specific: an <input type="radio"> made
|
|
5
|
+
// imperceptible (zero size, opacity 0, clipped off-screen) beside a visible
|
|
6
|
+
// <label for>. A person sees and clicks the label; a reader that enumerates the
|
|
7
|
+
// action space sees neither, because the label is not a control and the input is
|
|
8
|
+
// not perceivable. The option names sit in the page text with nothing to act on.
|
|
9
|
+
//
|
|
10
|
+
// Written for the idiom rather than for any one site, and first measured on
|
|
11
|
+
// WebShop, whose own stylesheet does exactly this to every product option.
|
|
12
|
+
// It is NOT part of the rule set Section 6 ran; it is the extension arm.
|
|
13
|
+
({
|
|
14
|
+
hidden_radio_group: {
|
|
15
|
+
find: () => {
|
|
16
|
+
const imperceptible = (el) => {
|
|
17
|
+
const s = getComputedStyle(el);
|
|
18
|
+
if (s.display === 'none' || s.visibility === 'hidden') return true;
|
|
19
|
+
if (+s.opacity === 0) return true;
|
|
20
|
+
const r = el.getBoundingClientRect();
|
|
21
|
+
return r.width < 2 || r.height < 2;
|
|
22
|
+
};
|
|
23
|
+
const visible = (el) => {
|
|
24
|
+
if (!el) return false;
|
|
25
|
+
const s = getComputedStyle(el);
|
|
26
|
+
if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false;
|
|
27
|
+
const r = el.getBoundingClientRect();
|
|
28
|
+
return r.width > 2 && r.height > 2;
|
|
29
|
+
};
|
|
30
|
+
const heads = new Map();
|
|
31
|
+
for (const r of document.querySelectorAll('input[type="radio"][name]')) {
|
|
32
|
+
if (r.closest('.rs')) continue;
|
|
33
|
+
if (!imperceptible(r)) continue; // a visible radio is fine as it is
|
|
34
|
+
const lab = r.id && document.querySelector('label[for="' + CSS.escape(r.id) + '"]');
|
|
35
|
+
if (!visible(lab)) continue; // hidden group, hidden label: not this pattern
|
|
36
|
+
if (!heads.has(r.name)) heads.set(r.name, lab);
|
|
37
|
+
}
|
|
38
|
+
return [...heads.values()];
|
|
39
|
+
},
|
|
40
|
+
extract: (el) => {
|
|
41
|
+
const first = document.getElementById(el.getAttribute('for'));
|
|
42
|
+
if (!first) return null;
|
|
43
|
+
const opts = [];
|
|
44
|
+
for (const r of document.querySelectorAll('input[type="radio"][name="' + CSS.escape(first.name) + '"]')) {
|
|
45
|
+
const lab = r.id && document.querySelector('label[for="' + CSS.escape(r.id) + '"]');
|
|
46
|
+
const text = lab ? rsRendered(lab) : (r.value || '');
|
|
47
|
+
if (text) opts.push({ value: text, label: text, radio: r, el: lab });
|
|
48
|
+
}
|
|
49
|
+
if (opts.length < 2) return null;
|
|
50
|
+
const chosen = opts.find((o) => o.radio.checked);
|
|
51
|
+
// The group's name is what the page already calls it: the radios' name
|
|
52
|
+
// attribute is the page's own word, and it is the only one there is. If a
|
|
53
|
+
// heading beside the group already says it, the replacement does not say
|
|
54
|
+
// it twice — a person would read the same word stacked on itself.
|
|
55
|
+
let head = el.parentElement;
|
|
56
|
+
let titled = false;
|
|
57
|
+
for (let i = 0; i < 3 && head; i++, head = head.parentElement) {
|
|
58
|
+
const h = head.querySelector('h1,h2,h3,h4,h5,legend,label:not([for])');
|
|
59
|
+
if (h && rsRendered(h).trim().toLowerCase() === String(first.name).toLowerCase()) {
|
|
60
|
+
titled = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { name: first.name, opts, current: chosen ? chosen.label : '', titled };
|
|
65
|
+
},
|
|
66
|
+
render: (d) => {
|
|
67
|
+
const id = 'hr-' + (++window.__rsSeq);
|
|
68
|
+
return `<div class="rs rs-field">
|
|
69
|
+
${d.titled ? '' : `<label class="rs-label" for="${id}">${rsEsc(d.name)}</label>`}
|
|
70
|
+
<select class="rs-select" id="${id}">
|
|
71
|
+
${d.current ? '' : `<option value="" selected>Choose ${rsEsc(d.name)}</option>`}
|
|
72
|
+
${d.opts.map((o) => `<option value="${rsEsc(o.value)}"${o.label === d.current ? ' selected' : ''}>${rsEsc(o.label)}</option>`).join('')}
|
|
73
|
+
</select>
|
|
74
|
+
</div>`;
|
|
75
|
+
},
|
|
76
|
+
// The labels are the widget as a person sees it; they are consumed by the
|
|
77
|
+
// swap so the page does not show the same choice twice.
|
|
78
|
+
removes: (d) => d.opts.map((o) => o.el).filter(Boolean),
|
|
79
|
+
after: (d, holder) => {
|
|
80
|
+
const shown = holder.querySelector('select.rs-select');
|
|
81
|
+
if (!shown) return;
|
|
82
|
+
for (const o of d.opts) if (o.el && o.el.isConnected) o.el.remove();
|
|
83
|
+
// Drive the page's own control rather than replacing its behaviour: the
|
|
84
|
+
// hidden radio still carries the handler the application installed.
|
|
85
|
+
shown.addEventListener('change', () => {
|
|
86
|
+
const want = shown.value;
|
|
87
|
+
const hit = d.opts.find((o) => o.value === want);
|
|
88
|
+
if (!hit) return;
|
|
89
|
+
hit.radio.style.display = '';
|
|
90
|
+
hit.radio.click();
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
})
|