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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// shadcn — BASE UI era (the default primitive since July 2026). Empirically
|
|
2
|
+
// probed on base-ui.com: the SELECT trigger is button[aria-haspopup="listbox"]
|
|
3
|
+
// with data-placeholder when unset — NOT role="combobox" as under Radix — which
|
|
4
|
+
// is exactly why this is a separate variant file. Menus keep the same ARIA
|
|
5
|
+
// surface; portal cleanup loses the data-radix-* markers. Tabs / accordion /
|
|
6
|
+
// switch detection is ARIA-level and identical across eras, duplicated here so
|
|
7
|
+
// each variant file stands alone (a rules file is data, not a library).
|
|
8
|
+
({
|
|
9
|
+
baseui_select: {
|
|
10
|
+
find: () => [...document.querySelectorAll('button[aria-haspopup="listbox"]')],
|
|
11
|
+
extract: (el) => {
|
|
12
|
+
const owned = el.getAttribute('aria-controls');
|
|
13
|
+
let box = owned && document.getElementById(owned);
|
|
14
|
+
if (!box) box = document.querySelector('[role="listbox"]');
|
|
15
|
+
const opts = box ? [...box.querySelectorAll('[role="option"]')]
|
|
16
|
+
.map(o => ({ value: o.getAttribute('data-value') || rsRendered(o),
|
|
17
|
+
label: rsRendered(o) })).filter(o => o.label) : [];
|
|
18
|
+
if (!opts.length) {
|
|
19
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
20
|
+
if (tries >= 3) return null;
|
|
21
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
22
|
+
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
23
|
+
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
|
24
|
+
el.click();
|
|
25
|
+
return 'retry';
|
|
26
|
+
}
|
|
27
|
+
const isPlaceholder = el.hasAttribute('data-placeholder');
|
|
28
|
+
const current = rsRendered(el);
|
|
29
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
30
|
+
return { label: rsLabel(el), opts, current, isPlaceholder, box,
|
|
31
|
+
id: el.getAttribute('id') || '' };
|
|
32
|
+
},
|
|
33
|
+
render: (d) => {
|
|
34
|
+
const id = d.id || ('rs-' + (++window.__rsSeq));
|
|
35
|
+
const sel = !d.isPlaceholder && d.opts.some(o => o.label === d.current) ? d.current : '';
|
|
36
|
+
return `<div class="rs rs-field">
|
|
37
|
+
${d.label ? `<label class="rs-label" for="${id}">${rsEsc(d.label)}</label>` : ''}
|
|
38
|
+
<select class="rs-select" id="${id}">
|
|
39
|
+
${sel ? '' : `<option value="" selected>${rsEsc(d.current || 'Select…')}</option>`}
|
|
40
|
+
${d.opts.map(o => `<option value="${rsEsc(o.value)}"${o.label === sel ? ' selected' : ''}>${rsEsc(o.label)}</option>`).join('')}
|
|
41
|
+
</select>
|
|
42
|
+
</div>`;
|
|
43
|
+
},
|
|
44
|
+
removes: (d) => d.box ? [d.box] : [],
|
|
45
|
+
// Base UI positions the popup in a floating wrapper without radix markers;
|
|
46
|
+
// removing the listbox itself is the safe, era-neutral cleanup.
|
|
47
|
+
after: (d) => { if (d.box && d.box.isConnected) d.box.remove(); },
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
baseui_menu: {
|
|
51
|
+
find: () => [...document.querySelectorAll('button[aria-haspopup="menu"]')]
|
|
52
|
+
// Never probe something that NAVIGATES or opens a dialog. Probing clicks,
|
|
53
|
+
// and on a documentation site the nav and the search both look like menu
|
|
54
|
+
// triggers: clicking them replaced the page and parity reported 130 severed
|
|
55
|
+
// navigation paths for a site that worked perfectly.
|
|
56
|
+
.filter(e => e.tagName !== 'A' && !e.getAttribute('href')
|
|
57
|
+
&& e.getAttribute('aria-haspopup') !== 'dialog'
|
|
58
|
+
&& !e.closest('[role="search"],form[role="search"],nav')),
|
|
59
|
+
|
|
60
|
+
extract: (el) => {
|
|
61
|
+
const owned = el.getAttribute('aria-controls');
|
|
62
|
+
let box = owned && document.getElementById(owned);
|
|
63
|
+
if (!box) box = document.querySelector('[role="menu"]');
|
|
64
|
+
const items = box ? [...box.querySelectorAll('[role^="menuitem"]')]
|
|
65
|
+
.map(o => ({ label: rsRendered(o),
|
|
66
|
+
// the anchor is often a child or ancestor of the menu item
|
|
67
|
+
href: (o.matches('a[href]') ? o : (o.querySelector('a[href]')
|
|
68
|
+
|| o.closest('a[href]')) || {}).href || '' }))
|
|
69
|
+
.filter(o => o.label) : [];
|
|
70
|
+
if (!items.length) {
|
|
71
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
72
|
+
if (tries >= 3) return null;
|
|
73
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
74
|
+
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
75
|
+
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
|
76
|
+
el.click();
|
|
77
|
+
return 'retry';
|
|
78
|
+
}
|
|
79
|
+
const label = rsRendered(el) || rsLabel(el);
|
|
80
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
81
|
+
return { label, items, box, orig: el };
|
|
82
|
+
},
|
|
83
|
+
render: (d) => `<div class="rs rs-field" data-rs-menu>
|
|
84
|
+
<span class="rs-label">${rsEsc(d.label)}</span>
|
|
85
|
+
<ul class="rs-pages-list">${d.items.map((i, n) =>
|
|
86
|
+
i.href ? `<li><a href="${rsEsc(i.href)}">${rsEsc(i.label)}</a></li>`
|
|
87
|
+
: `<li><button type="button" class="rs-iconbtn" data-rs-item="${n}">${rsEsc(i.label)}</button></li>`).join('')}</ul>
|
|
88
|
+
</div>`,
|
|
89
|
+
removes: (d) => d.box ? [d.box] : [],
|
|
90
|
+
after: (d, holder) => {
|
|
91
|
+
if (d.box && d.box.isConnected) d.box.remove();
|
|
92
|
+
d.orig.style.display = 'none';
|
|
93
|
+
d.orig.removeAttribute('data-rs-done');
|
|
94
|
+
holder.appendChild(d.orig);
|
|
95
|
+
holder.querySelectorAll('[data-rs-item]').forEach(btn => {
|
|
96
|
+
btn.addEventListener('click', () => {
|
|
97
|
+
const want = btn.textContent.trim();
|
|
98
|
+
d.orig.click();
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
const it = [...document.querySelectorAll('[role^="menuitem"]')]
|
|
101
|
+
.find(m => rsRendered(m) === want);
|
|
102
|
+
if (it) it.click();
|
|
103
|
+
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
104
|
+
}, 120);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// ARIA-level families shared across eras (same shapes as the radix file).
|
|
111
|
+
baseui_tabs: {
|
|
112
|
+
// flattening: the tab/section controls exist to reveal content this
|
|
113
|
+
// output reveals structurally, so they are subsumed, not lost
|
|
114
|
+
subsumes: true,
|
|
115
|
+
find: () => [...document.querySelectorAll('[role="tablist"]')]
|
|
116
|
+
.filter(t => t.querySelector('[role="tab"][aria-controls]')),
|
|
117
|
+
extract: (el) => {
|
|
118
|
+
const trigs = [...el.querySelectorAll('[role="tab"]')];
|
|
119
|
+
const st = el.__rsCap || (el.__rsCap = { caps: {} });
|
|
120
|
+
let missing = null;
|
|
121
|
+
trigs.forEach(t => {
|
|
122
|
+
const key = rsRendered(t);
|
|
123
|
+
const panel = document.getElementById(t.getAttribute('aria-controls') || '');
|
|
124
|
+
if (panel && rsRendered(panel)) st.caps[key] = { title: key, html: panel.innerHTML, panel };
|
|
125
|
+
else if (!(key in st.caps) && !missing) missing = t;
|
|
126
|
+
});
|
|
127
|
+
if (missing) {
|
|
128
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
129
|
+
if (tries < trigs.length * 2 + 2) {
|
|
130
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
131
|
+
missing.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
132
|
+
missing.focus();
|
|
133
|
+
missing.click();
|
|
134
|
+
return 'retry';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const tabs = trigs.map(t => st.caps[rsRendered(t)]).filter(Boolean);
|
|
138
|
+
return tabs.length >= 2 ? { tabs } : null;
|
|
139
|
+
},
|
|
140
|
+
render: (d) => `<div class="rs rs-sections">` + d.tabs.map(t =>
|
|
141
|
+
`<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
|
|
142
|
+
<div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
|
|
143
|
+
removes: (d) => d.tabs.map(t => t.panel),
|
|
144
|
+
after: (d) => d.tabs.forEach(t => t.panel && t.panel.isConnected && t.panel.remove()),
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
baseui_switch: {
|
|
148
|
+
find: () => [...document.querySelectorAll('button[role="switch"]')],
|
|
149
|
+
extract: (el) => ({
|
|
150
|
+
label: rsLabel(el) || rsRendered(el) || 'Toggle',
|
|
151
|
+
checked: el.getAttribute('aria-checked') === 'true',
|
|
152
|
+
orig: el,
|
|
153
|
+
}),
|
|
154
|
+
render: (d) => {
|
|
155
|
+
const id = 'rs-' + (++window.__rsSeq);
|
|
156
|
+
return `<div class="rs rs-field" data-rs-switch>
|
|
157
|
+
<label class="rs-label" for="${id}">${rsEsc(d.label)}</label>
|
|
158
|
+
<input type="checkbox" id="${id}"${d.checked ? ' checked' : ''}>
|
|
159
|
+
</div>`;
|
|
160
|
+
},
|
|
161
|
+
after: (d, holder) => {
|
|
162
|
+
d.orig.style.display = 'none';
|
|
163
|
+
d.orig.removeAttribute('data-rs-done');
|
|
164
|
+
holder.appendChild(d.orig);
|
|
165
|
+
holder.querySelector('input[type="checkbox"]')
|
|
166
|
+
.addEventListener('change', () => d.orig.click());
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
})
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// shadcn — RADIX era (the default primitive until July 2026; every widget rides
|
|
2
|
+
// @radix-ui). Its defining hostility: a trigger
|
|
3
|
+
// <button role="combobox" aria-expanded="false"> whose options DO NOT EXIST in
|
|
4
|
+
// the DOM until the portal opens — the reason it scores in the benchmark's bottom
|
|
5
|
+
// tier. Passive extraction can only skip, so this rule probes: it opens the
|
|
6
|
+
// portal (the page's own interaction), harvests the options the page itself
|
|
7
|
+
// renders, closes it, and replaces trigger+portal with a native select. Every
|
|
8
|
+
// option label comes from the page; nothing is guessed.
|
|
9
|
+
({
|
|
10
|
+
radix_select: {
|
|
11
|
+
find: () => [...document.querySelectorAll('button[role="combobox"]')],
|
|
12
|
+
extract: (el) => {
|
|
13
|
+
// Radix links trigger to the portalled listbox via aria-controls once open.
|
|
14
|
+
const owned = el.getAttribute('aria-controls');
|
|
15
|
+
const box = owned && document.getElementById(owned);
|
|
16
|
+
const opts = box ? [...box.querySelectorAll('[role="option"]')]
|
|
17
|
+
.map(o => ({ value: o.getAttribute('data-value') || rsRendered(o),
|
|
18
|
+
label: rsRendered(o) })).filter(o => o.label) : [];
|
|
19
|
+
if (!opts.length) {
|
|
20
|
+
// Phase 1: not open yet. Open it and wait for the mutation pass.
|
|
21
|
+
// Guard so a select that never yields options doesn't loop forever.
|
|
22
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
23
|
+
if (tries >= 3) return null; // give up -> honest skip
|
|
24
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
25
|
+
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
26
|
+
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
|
27
|
+
el.click();
|
|
28
|
+
return 'retry';
|
|
29
|
+
}
|
|
30
|
+
// Phase 2: harvest, then close the portal before it is orphaned.
|
|
31
|
+
const label = rsLabel(el);
|
|
32
|
+
const current = rsRendered(el); // the trigger shows the value
|
|
33
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
34
|
+
return { label, opts, current, box, trigger: el,
|
|
35
|
+
id: el.getAttribute('id') || '' };
|
|
36
|
+
},
|
|
37
|
+
render: (d) => {
|
|
38
|
+
const id = d.id || ('rs-' + (++window.__rsSeq));
|
|
39
|
+
const sel = d.opts.some(o => o.label === d.current) ? d.current : '';
|
|
40
|
+
return `<div class="rs rs-field">
|
|
41
|
+
${d.label ? `<label class="rs-label" for="${id}">${rsEsc(d.label)}</label>` : ''}
|
|
42
|
+
<select class="rs-select" id="${id}">
|
|
43
|
+
${sel ? '' : `<option value="" selected>${rsEsc(d.current || 'Select…')}</option>`}
|
|
44
|
+
${d.opts.map(o => `<option value="${rsEsc(o.value)}"${o.label === sel ? ' selected' : ''}>${rsEsc(o.label)}</option>`).join('')}
|
|
45
|
+
</select>
|
|
46
|
+
</div>`;
|
|
47
|
+
},
|
|
48
|
+
// the portalled listbox is consumed by the swap, not left floating
|
|
49
|
+
removes: (d) => d.box ? [d.box] : [],
|
|
50
|
+
after: (d, holder) => {
|
|
51
|
+
// Remove only the listbox, never Radix's positioning wrapper: that wrapper
|
|
52
|
+
// can contain more than the widget, and removing it took a site's whole
|
|
53
|
+
// navigation once.
|
|
54
|
+
if (d.box && d.box.isConnected) d.box.remove();
|
|
55
|
+
// SHADOW-DRIVE the trigger. Without this the replacement displays a choice
|
|
56
|
+
// the application never receives: React holds the state, the rebuilt <select>
|
|
57
|
+
// has no connection to it, and a user or agent selects an option that changes
|
|
58
|
+
// nothing. Parity saw the trigger as lost and was right to; effect parity
|
|
59
|
+
// could not see it at all, because it only exercises controls both arms name
|
|
60
|
+
// and a replaced trigger is by definition not one of those.
|
|
61
|
+
if (!d.trigger) return;
|
|
62
|
+
d.trigger.style.display = 'none';
|
|
63
|
+
d.trigger.setAttribute('data-rs-done', '');
|
|
64
|
+
holder.appendChild(d.trigger);
|
|
65
|
+
const shown = holder.querySelector('select.rs-select');
|
|
66
|
+
if (!shown) return;
|
|
67
|
+
shown.addEventListener('change', () => {
|
|
68
|
+
const want = shown.selectedOptions[0] && shown.selectedOptions[0].textContent.trim();
|
|
69
|
+
if (!want) return;
|
|
70
|
+
// Drive the real widget the way a person would: open it, then choose.
|
|
71
|
+
d.trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
72
|
+
d.trigger.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
|
73
|
+
d.trigger.click();
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
const opt = [...document.querySelectorAll('[role="option"]')]
|
|
76
|
+
.find(o => rsRendered(o) === want);
|
|
77
|
+
if (opt) opt.click();
|
|
78
|
+
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
79
|
+
}, 140);
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// ── DropdownMenu: portal probing like the select, but items may be ACTIONS.
|
|
85
|
+
// Read items are rendered as real links; action items shadow-drive the original
|
|
86
|
+
// trigger (open the real menu, click the real item) so behaviour is preserved.
|
|
87
|
+
radix_menu: {
|
|
88
|
+
find: () => [...document.querySelectorAll('button[aria-haspopup="menu"]')]
|
|
89
|
+
// Never probe something that NAVIGATES or opens a dialog. Probing clicks,
|
|
90
|
+
// and on a documentation site the nav and the search both look like menu
|
|
91
|
+
// triggers: clicking them replaced the page and parity reported 130 severed
|
|
92
|
+
// navigation paths for a site that worked perfectly.
|
|
93
|
+
.filter(e => e.tagName !== 'A' && !e.getAttribute('href')
|
|
94
|
+
&& e.getAttribute('aria-haspopup') !== 'dialog'
|
|
95
|
+
&& !e.closest('[role="search"],form[role="search"],nav')),
|
|
96
|
+
|
|
97
|
+
extract: (el) => {
|
|
98
|
+
const owned = el.getAttribute('aria-controls');
|
|
99
|
+
const box = owned && document.getElementById(owned);
|
|
100
|
+
const items = box ? [...box.querySelectorAll('[role^="menuitem"]')]
|
|
101
|
+
.map(o => ({ label: rsRendered(o),
|
|
102
|
+
// the anchor is often a child or ancestor of the menu item
|
|
103
|
+
href: (o.matches('a[href]') ? o : (o.querySelector('a[href]')
|
|
104
|
+
|| o.closest('a[href]')) || {}).href || '' }))
|
|
105
|
+
.filter(o => o.label) : [];
|
|
106
|
+
if (!items.length) {
|
|
107
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
108
|
+
if (tries >= 3) return null;
|
|
109
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
110
|
+
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
|
111
|
+
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
|
|
112
|
+
el.click();
|
|
113
|
+
return 'retry';
|
|
114
|
+
}
|
|
115
|
+
const label = rsRendered(el) || rsLabel(el);
|
|
116
|
+
// A menu holds more than its items: a heading, an account name, an email, a
|
|
117
|
+
// separator caption. Harvesting only [role^=menuitem] deleted 'shadcn',
|
|
118
|
+
// 'm@example.com' and 'Toggle columns' from a page that shows all three.
|
|
119
|
+
const captions = [...box.children]
|
|
120
|
+
.filter(c => !c.matches('[role^="menuitem"],[role="separator"]'))
|
|
121
|
+
.map(c => rsRendered(c)).filter(Boolean)
|
|
122
|
+
.filter(t => !items.some(i => i.label === t));
|
|
123
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
124
|
+
return { label, items, captions, box, orig: el };
|
|
125
|
+
},
|
|
126
|
+
render: (d) => `<div class="rs rs-field" data-rs-menu>
|
|
127
|
+
<span class="rs-label">${rsEsc(d.label)}</span>
|
|
128
|
+
${(d.captions || []).map(c => `<p class="rs-label">${rsEsc(c)}</p>`).join('')}
|
|
129
|
+
<ul class="rs-pages-list">${d.items.map((i, n) =>
|
|
130
|
+
i.href ? `<li><a href="${rsEsc(i.href)}">${rsEsc(i.label)}</a></li>`
|
|
131
|
+
: `<li><button type="button" class="rs-iconbtn" data-rs-item="${n}">${rsEsc(i.label)}</button></li>`).join('')}</ul>
|
|
132
|
+
</div>`,
|
|
133
|
+
removes: (d) => d.box ? [d.box] : [],
|
|
134
|
+
after: (d, holder) => {
|
|
135
|
+
// Remove ONLY the menu we harvested. Removing Radix's positioning wrapper
|
|
136
|
+
// took whatever else it contained: on ui.shadcn.com that was the site's main
|
|
137
|
+
// navigation, and every /docs/... destination vanished while the faithfulness
|
|
138
|
+
// check stayed green because the words were still elsewhere on the page.
|
|
139
|
+
// Capability is not text.
|
|
140
|
+
if (d.box && d.box.isConnected) d.box.remove();
|
|
141
|
+
// shadow-drive: the original trigger rides along hidden; an action item
|
|
142
|
+
// re-opens the real menu and clicks the real item, so React state is real
|
|
143
|
+
d.orig.style.display = 'none';
|
|
144
|
+
d.orig.removeAttribute('data-rs-done');
|
|
145
|
+
holder.appendChild(d.orig);
|
|
146
|
+
holder.querySelectorAll('[data-rs-item]').forEach(btn => {
|
|
147
|
+
btn.addEventListener('click', () => {
|
|
148
|
+
const want = btn.textContent.trim();
|
|
149
|
+
d.orig.click();
|
|
150
|
+
setTimeout(() => {
|
|
151
|
+
const it = [...document.querySelectorAll('[role^="menuitem"]')]
|
|
152
|
+
.find(m => rsRendered(m) === want);
|
|
153
|
+
if (it) it.click();
|
|
154
|
+
else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
155
|
+
}, 120);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// ── Tabs: the inactive panel NODE exists but its content is unmounted, so a
|
|
162
|
+
// passive read sees an empty region. Probe: click each tab once, harvesting
|
|
163
|
+
// panels as they mount; when all are captured, replace with plain sections.
|
|
164
|
+
radix_tabs: {
|
|
165
|
+
// flattening: the tab/section controls exist to reveal content this
|
|
166
|
+
// output reveals structurally, so they are subsumed, not lost
|
|
167
|
+
subsumes: true,
|
|
168
|
+
find: () => [...document.querySelectorAll('[role="tablist"]')]
|
|
169
|
+
.filter(t => t.querySelector('[role="tab"][aria-controls]')),
|
|
170
|
+
extract: (el) => {
|
|
171
|
+
const trigs = [...el.querySelectorAll('[role="tab"]')];
|
|
172
|
+
const st = el.__rsCap || (el.__rsCap = { caps: {}, active0: null });
|
|
173
|
+
let missing = null;
|
|
174
|
+
trigs.forEach(t => {
|
|
175
|
+
const key = rsRendered(t);
|
|
176
|
+
if (t.getAttribute('data-state') === 'active' && st.active0 === null) st.active0 = key;
|
|
177
|
+
const panel = document.getElementById(t.getAttribute('aria-controls') || '');
|
|
178
|
+
if (panel && rsRendered(panel)) st.caps[key] = { title: key, html: panel.innerHTML, panel };
|
|
179
|
+
else if (!(key in st.caps) && !missing) missing = t;
|
|
180
|
+
});
|
|
181
|
+
if (missing) {
|
|
182
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
183
|
+
if (tries < trigs.length * 2 + 2) {
|
|
184
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
185
|
+
missing.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
186
|
+
missing.focus();
|
|
187
|
+
missing.click();
|
|
188
|
+
return 'retry';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const tabs = trigs.map(t => st.caps[rsRendered(t)]).filter(Boolean);
|
|
192
|
+
return tabs.length >= 2 ? { tabs } : null;
|
|
193
|
+
},
|
|
194
|
+
render: (d) => `<div class="rs rs-sections">` + d.tabs.map(t =>
|
|
195
|
+
`<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
|
|
196
|
+
<div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
|
|
197
|
+
removes: (d) => d.tabs.map(t => t.panel),
|
|
198
|
+
after: (d) => d.tabs.forEach(t => t.panel && t.panel.isConnected && t.panel.remove()),
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
// ── Accordion: same unmounted-content shape as tabs, different markup — a
|
|
202
|
+
// container of >= 2 button[aria-expanded][aria-controls] triggers.
|
|
203
|
+
radix_accordion: {
|
|
204
|
+
// flattening: the tab/section controls exist to reveal content this
|
|
205
|
+
// output reveals structurally, so they are subsumed, not lost
|
|
206
|
+
subsumes: true,
|
|
207
|
+
// Some shadcn accordions ship WITHOUT aria-controls (the arm we measure
|
|
208
|
+
// does), so association falls back to structure: the trigger's item wraps
|
|
209
|
+
// both the button and its content region.
|
|
210
|
+
find: () => {
|
|
211
|
+
const trigs = [...document.querySelectorAll('button[aria-expanded]')]
|
|
212
|
+
.filter(b => !b.closest('[role="tablist"]') && !b.getAttribute('aria-haspopup')
|
|
213
|
+
&& b.getAttribute('role') !== 'combobox');
|
|
214
|
+
const roots = new Set();
|
|
215
|
+
trigs.forEach(t => {
|
|
216
|
+
let r = t.parentElement;
|
|
217
|
+
for (let i = 0; r && i < 4; i++, r = r.parentElement) {
|
|
218
|
+
if ([...r.querySelectorAll(':scope button[aria-expanded]')].length >= 2) {
|
|
219
|
+
roots.add(r); return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return [...roots];
|
|
224
|
+
},
|
|
225
|
+
extract: (el) => {
|
|
226
|
+
const trigs = [...el.querySelectorAll('button[aria-expanded]')];
|
|
227
|
+
const panelOf = (t) => {
|
|
228
|
+
const id = t.getAttribute('aria-controls');
|
|
229
|
+
if (id && document.getElementById(id)) return document.getElementById(id);
|
|
230
|
+
// structural: the nearest wrapper that also holds a non-trigger block
|
|
231
|
+
let item = t.parentElement;
|
|
232
|
+
for (let i = 0; item && i < 3; i++, item = item.parentElement) {
|
|
233
|
+
const cand = [...item.children].find(c => c !== t && !c.contains(t)
|
|
234
|
+
&& !c.querySelector('button[aria-expanded]'));
|
|
235
|
+
if (cand) return cand;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
};
|
|
239
|
+
const st = el.__rsCap || (el.__rsCap = { caps: {} });
|
|
240
|
+
let missing = null;
|
|
241
|
+
trigs.forEach(t => {
|
|
242
|
+
const key = rsRendered(t);
|
|
243
|
+
const panel = panelOf(t);
|
|
244
|
+
if (panel && rsRendered(panel)) st.caps[key] = { title: key, html: panel.innerHTML, panel };
|
|
245
|
+
else if (!(key in st.caps) && !missing) missing = t;
|
|
246
|
+
});
|
|
247
|
+
if (missing) {
|
|
248
|
+
const tries = +(el.getAttribute('data-rs-probe') || 0);
|
|
249
|
+
if (tries < trigs.length * 2 + 2) {
|
|
250
|
+
el.setAttribute('data-rs-probe', String(tries + 1));
|
|
251
|
+
missing.click();
|
|
252
|
+
return 'retry';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const secs = trigs.map(t => st.caps[rsRendered(t)]).filter(Boolean);
|
|
256
|
+
return secs.length >= 2 ? { secs } : null;
|
|
257
|
+
},
|
|
258
|
+
render: (d) => `<div class="rs rs-sections">` + d.secs.map(t =>
|
|
259
|
+
`<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
|
|
260
|
+
<div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
|
|
261
|
+
removes: (d) => d.secs.map(t => t.panel),
|
|
262
|
+
after: (d) => d.secs.forEach(t => t.panel && t.panel.isConnected && t.panel.remove()),
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
// ── Switch: a button pretending to be a checkbox. Replace with a real one that
|
|
266
|
+
// shadow-drives the original, so React state stays the truth.
|
|
267
|
+
radix_switch: {
|
|
268
|
+
find: () => [...document.querySelectorAll('button[role="switch"]')],
|
|
269
|
+
extract: (el) => ({
|
|
270
|
+
label: rsLabel(el) || rsRendered(el) || 'Toggle',
|
|
271
|
+
checked: el.getAttribute('aria-checked') === 'true',
|
|
272
|
+
orig: el,
|
|
273
|
+
}),
|
|
274
|
+
render: (d) => {
|
|
275
|
+
const id = 'rs-' + (++window.__rsSeq);
|
|
276
|
+
return `<div class="rs rs-field" data-rs-switch>
|
|
277
|
+
<label class="rs-label" for="${id}">${rsEsc(d.label)}</label>
|
|
278
|
+
<input type="checkbox" id="${id}"${d.checked ? ' checked' : ''}>
|
|
279
|
+
</div>`;
|
|
280
|
+
},
|
|
281
|
+
after: (d, holder) => {
|
|
282
|
+
d.orig.style.display = 'none';
|
|
283
|
+
d.orig.removeAttribute('data-rs-done');
|
|
284
|
+
holder.appendChild(d.orig);
|
|
285
|
+
const box = holder.querySelector('input[type="checkbox"]');
|
|
286
|
+
box.addEventListener('change', () => d.orig.click());
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
})
|