Skip to content

Commit e8f4cd0

Browse files
authored
Merge pull request #310 from mean-weasel/codex/configurable-feedback-variants-phase-3
feat: add composable feedback variants
2 parents b336d66 + 0bfa9bf commit e8f4cd0

19 files changed

Lines changed: 1245 additions & 20 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ test-radix-e2e:
5353
echo "Usage: make test-radix-e2e BROWSER=chromium|firefox|webkit"; \
5454
exit 1; \
5555
fi
56-
npx playwright test e2e/widget.radix.spec.ts --project=$(BROWSER)-radix --workers=1
56+
npx playwright test e2e/widget.radix.spec.ts e2e/variant-modal.radix.spec.ts e2e/variant-accessibility.radix.spec.ts --project=$(BROWSER)-radix --workers=1 --retries=0
5757

5858
test-live-radix:
5959
@if [ -z "$(LIVE_TARGET)" ] || [ -z "$(PLAYWRIGHT_BASE_URL)" ]; then \

docs/website/javascript-api.mdx

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,81 @@ mountedReview.reset();
177177
mountedReview.unmount();
178178
```
179179

180-
The inline renderer provides accessible short-text, long-text, and rating controls. Choosing a star
181-
changes only local form state; it never submits. A GitHub Issue is created only after the explicit
182-
Submit button succeeds.
180+
The inline renderer provides accessible short-text, long-text, rating, and single-choice controls.
181+
Choosing an option changes only local form state; it never submits. A GitHub Issue is created only
182+
after the explicit Submit button succeeds.
183+
184+
For example, an inline poll keeps stable answer values separate from its visible labels and optional
185+
descriptions. `radio`, `cards`, and `buttons` displays all retain native radio keyboard semantics:
186+
187+
```js
188+
const poll = window.BugDrop.registerVariant({
189+
id: 'next-integration-poll',
190+
presentation: { kind: 'inline' },
191+
content: { title: 'What should we build next?', submitLabel: 'Vote' },
192+
fields: [
193+
{
194+
id: 'choice',
195+
type: 'singleChoice',
196+
label: 'Choose one',
197+
required: true,
198+
display: 'cards',
199+
options: [
200+
{ value: 'onedrive', label: 'OneDrive' },
201+
{ value: 'box', label: 'Box' },
202+
{ value: 'other', label: 'Something else' },
203+
],
204+
},
205+
{ id: 'detail', type: 'longText', label: 'Optional detail', maxLength: 500 },
206+
],
207+
issue: {
208+
classification: 'feature',
209+
title: 'Integration vote — {{choice}}',
210+
sections: [
211+
{ heading: 'Choice', field: 'choice', format: 'choice' },
212+
{ heading: 'Detail', field: 'detail', omitWhenEmpty: true },
213+
],
214+
},
215+
});
216+
217+
const mountedPoll = poll.mount(document.querySelector('#poll-slot'));
218+
```
219+
220+
The title template receives the stable configured value (for example, `onedrive`), while the
221+
`choice` section renders its display label (`OneDrive`). Configured labels and descriptions are
222+
inserted as text. Resetting a successful mounted poll restores its initial answers and creates a new
223+
submission identity; `unmount()` disposes only that instance.
224+
225+
Compact suggestions do not need a special renderer or field. Compose the existing short- and
226+
long-text controllers in the modal presentation:
227+
228+
```js
229+
const suggestion = window.BugDrop.registerVariant({
230+
id: 'compact-suggestion',
231+
presentation: { kind: 'modal', size: 'default' },
232+
content: { title: 'Share an idea', submitLabel: 'Submit idea' },
233+
fields: [
234+
{ id: 'summary', type: 'shortText', label: 'Idea', required: true, maxLength: 120 },
235+
{ id: 'detail', type: 'longText', label: 'How would this help?', maxLength: 2000 },
236+
],
237+
issue: {
238+
classification: 'feature',
239+
title: '[Idea] {{summary}}',
240+
sections: [
241+
{ heading: 'Idea', field: 'summary' },
242+
{ heading: 'Why it would help', field: 'detail', omitWhenEmpty: true },
243+
],
244+
},
245+
});
246+
247+
suggestion.open();
248+
```
249+
250+
Every built-in controller follows the same internal controller contract for values, errors,
251+
disabled state, focus, reset, and disposal. Contributors extending the built-in field union add a
252+
controller and its shared conformance fixture; the browser still emits generic Issue sections, so
253+
the Worker formatter does not gain a field-specific branch. A public renderer registration API is
254+
not part of this contract.
183255

184256
For a host-owned CTA, register a modal variant and call its handle instead of the legacy
185257
`BugDrop.open()` method:
@@ -222,10 +294,17 @@ as `submitted`, `closed`, or `busy`. A variant requested while the legacy wizard
222294
`busy`. Opening the legacy wizard while a variant modal is active closes the variant first. The
223295
legacy `BugDrop.close()` method remains legacy-scoped.
224296

225-
BugDrop's merge-queue preview checks render both this CTA modal and the inline star review from the
226-
exact deployed widget bytes. They assert each normalized draft without creating Issues, followed by
227-
one zero-retry rendered CTA canary that creates, independently verifies, closes, and sweeps one real
228-
GitHub Issue through the existing GitHub App.
297+
BugDrop's merge-queue preview checks render the star review, CTA question, poll, and compact
298+
suggestion from the exact deployed widget bytes. Each UX intercepts and asserts its normalized draft
299+
without creating an Issue. A separate zero-retry representative canary then creates, independently
300+
verifies, closes, and sweeps exactly one real GitHub Issue through the shared Worker and GitHub App
301+
path; browser and UX coverage never multiply that destructive canary.
302+
303+
For component cleanup, keep the returned mounted or opened handle. Call `unmount()` for inline
304+
instances and `close()` for an open modal. Both are safe to call repeatedly and dispose only their
305+
own host, controller state, and listeners. `reset()` restores the supplied initial answers and, after
306+
a successful submission, starts a new submission identity without affecting another mounted
307+
instance or the legacy widget.
229308

230309
If your application owns the UI, the same immutable handle also supports headless submission:
231310

e2e/variant-accessibility.radix.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,134 @@ test('rating keyboard behavior requires explicit Submit', async ({ page }) => {
5454
expect(submissionCount).toBe(1);
5555
});
5656

57+
test('single-choice keyboard behavior requires explicit Submit', async ({ page }) => {
58+
let submissionCount = 0;
59+
await page.route('**/feedback', route => {
60+
if (route.request().method() !== 'POST') return route.continue();
61+
submissionCount += 1;
62+
return route.fulfill({
63+
status: 200,
64+
contentType: 'application/json',
65+
body: JSON.stringify({
66+
success: true,
67+
issueNumber: 302,
68+
issueUrl: 'https://github.com/mean-weasel/bugdrop-widget-test/issues/302',
69+
isPublic: false,
70+
}),
71+
});
72+
});
73+
await page.goto('/test/');
74+
await expect
75+
.poll(() => page.evaluate(() => typeof window.BugDrop?.registerVariant))
76+
.toBe('function');
77+
await page.evaluate(() => {
78+
const slot = document.createElement('div');
79+
slot.id = 'cross-browser-poll-slot';
80+
document.body.appendChild(slot);
81+
window
82+
.BugDrop!.registerVariant({
83+
id: 'cross-browser-poll',
84+
presentation: { kind: 'inline' },
85+
content: { title: 'Choose an integration', submitLabel: 'Submit vote' },
86+
fields: [
87+
{
88+
id: 'choice',
89+
type: 'singleChoice',
90+
label: 'Integration',
91+
required: true,
92+
display: 'buttons',
93+
options: [
94+
{ value: 'onedrive', label: 'OneDrive' },
95+
{ value: 'box', label: 'Box' },
96+
{ value: 'other', label: 'Something else' },
97+
],
98+
},
99+
],
100+
issue: { title: 'Integration vote {{choice}}' },
101+
})
102+
.mount(slot);
103+
});
104+
105+
const host = page.locator('#cross-browser-poll-slot > [data-bugdrop-owned]');
106+
const choices = host.getByRole('radiogroup', { name: 'Integration' });
107+
const submit = host.getByRole('button', { name: 'Submit vote' });
108+
await submit.click();
109+
await expect(choices).toHaveAttribute('aria-invalid', 'true');
110+
const oneDrive = choices.getByRole('radio', { name: 'OneDrive' });
111+
await expect(oneDrive).toBeFocused();
112+
await oneDrive.press('ArrowRight');
113+
const box = choices.getByRole('radio', { name: 'Box' });
114+
await expect(box).toBeFocused();
115+
await expect(box).toBeChecked();
116+
await box.press('Enter');
117+
await box.press('Space');
118+
expect(submissionCount).toBe(0);
119+
await submit.click();
120+
await expect(host.getByRole('heading', { name: 'Thanks for your feedback!' })).toBeVisible();
121+
expect(submissionCount).toBe(1);
122+
});
123+
124+
test('compact suggestion validates and submits explicitly across browser engines', async ({
125+
page,
126+
}) => {
127+
let submissionCount = 0;
128+
await page.route('**/feedback', route => {
129+
if (route.request().method() !== 'POST') return route.continue();
130+
submissionCount += 1;
131+
return route.fulfill({
132+
status: 200,
133+
contentType: 'application/json',
134+
body: JSON.stringify({
135+
success: true,
136+
issueNumber: 303,
137+
issueUrl: 'https://github.com/mean-weasel/bugdrop-widget-test/issues/303',
138+
isPublic: false,
139+
}),
140+
});
141+
});
142+
await page.goto('/test/');
143+
await expect
144+
.poll(() => page.evaluate(() => typeof window.BugDrop?.registerVariant))
145+
.toBe('function');
146+
await page.evaluate(() => {
147+
window
148+
.BugDrop!.registerVariant({
149+
id: 'cross-browser-compact-suggestion',
150+
presentation: { kind: 'modal', size: 'default' },
151+
content: { title: 'Share an idea', submitLabel: 'Submit idea' },
152+
fields: [
153+
{ id: 'summary', type: 'shortText', label: 'Idea', required: true, maxLength: 120 },
154+
{ id: 'detail', type: 'longText', label: 'How would this help?', maxLength: 2_000 },
155+
],
156+
issue: {
157+
title: '[Idea] {{summary}}',
158+
sections: [
159+
{ heading: 'Idea', field: 'summary' },
160+
{ heading: 'Why it would help', field: 'detail', omitWhenEmpty: true },
161+
],
162+
},
163+
})
164+
.open();
165+
});
166+
167+
const host = page.locator('body > [data-bugdrop-owned]');
168+
const summary = host.getByRole('textbox', { name: 'Idea' });
169+
const submit = host.getByRole('button', { name: 'Submit idea' });
170+
await submit.click();
171+
await expect(summary).toHaveAttribute('aria-invalid', 'true');
172+
await expect(summary).toBeFocused();
173+
expect(submissionCount).toBe(0);
174+
await summary.fill('Keyboard-friendly compact form');
175+
await summary.press('Enter');
176+
expect(submissionCount).toBe(0);
177+
expect(
178+
await submit.evaluate(element => element.getBoundingClientRect().height)
179+
).toBeGreaterThanOrEqual(44);
180+
await submit.click();
181+
await expect(host.getByRole('heading', { name: 'Thanks for your feedback!' })).toBeVisible();
182+
expect(submissionCount).toBe(1);
183+
});
184+
57185
test('modal focus is contained and Escape restores the host page', async ({ page }) => {
58186
await page.goto('/test/');
59187
await expect

e2e/variant-inline.spec.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,119 @@ test.describe('rendered inline variants', () => {
222222
'true'
223223
);
224224
});
225+
226+
test('submits an exact single-choice poll draft only through explicit Submit and resets identity', async ({
227+
page,
228+
}) => {
229+
const submissions: Array<Record<string, unknown>> = [];
230+
await page.route('**/api/feedback', route => {
231+
submissions.push(route.request().postDataJSON() as Record<string, unknown>);
232+
return route.fulfill({
233+
status: 200,
234+
contentType: 'application/json',
235+
body: JSON.stringify({
236+
success: true,
237+
issueNumber: 106,
238+
issueUrl: 'https://github.com/mean-weasel/bugdrop-widget-test/issues/106',
239+
isPublic: false,
240+
}),
241+
});
242+
});
243+
await page.goto('/test/');
244+
await page.locator('#bugdrop-host').locator('css=.bd-trigger').waitFor();
245+
await page.evaluate(() => {
246+
const slot = document.createElement('div');
247+
slot.id = 'poll-slot';
248+
document.body.appendChild(slot);
249+
const mounted = window
250+
.BugDrop!.registerVariant({
251+
id: 'next-integration-poll',
252+
presentation: { kind: 'inline' },
253+
content: { title: 'What should we build next?', submitLabel: 'Vote' },
254+
fields: [
255+
{
256+
id: 'choice',
257+
type: 'singleChoice',
258+
label: 'Choose one',
259+
required: true,
260+
display: 'cards',
261+
options: [
262+
{
263+
value: 'onedrive-stable',
264+
label: '<OneDrive>',
265+
description: 'Microsoft storage',
266+
},
267+
{ value: 'box-stable', label: 'Box', description: 'Secure content cloud' },
268+
],
269+
},
270+
{ id: 'detail', type: 'longText', label: 'Optional detail', maxLength: 500 },
271+
],
272+
issue: {
273+
classification: 'feature',
274+
title: 'Integration vote — {{choice}}',
275+
sections: [
276+
{ heading: 'Choice', field: 'choice', format: 'choice' },
277+
{ heading: 'Detail', field: 'detail', omitWhenEmpty: true },
278+
],
279+
},
280+
})
281+
.mount(slot);
282+
(window as Window & { __poll?: { reset(): void; unmount(): void } }).__poll = mounted;
283+
});
284+
285+
const host = page.locator('#poll-slot > [data-bugdrop-owned]');
286+
const group = host.getByRole('radiogroup', { name: 'Choose one' });
287+
const submit = host.getByRole('button', { name: 'Vote' });
288+
await submit.click();
289+
await expect(group).toHaveAttribute('aria-invalid', 'true');
290+
const oneDrive = group.getByRole('radio', { name: '<OneDrive> Microsoft storage' });
291+
await expect(oneDrive).toBeFocused();
292+
expect(submissions).toHaveLength(0);
293+
await oneDrive.click();
294+
expect(submissions).toHaveLength(0);
295+
await expect(host.locator('img')).toHaveCount(0);
296+
expect(
297+
await oneDrive.evaluate(element => element.closest('label')!.getBoundingClientRect().height)
298+
).toBeGreaterThanOrEqual(44);
299+
await host.getByRole('textbox', { name: 'Optional detail' }).fill(' Please add sync. ');
300+
expect(submissions).toHaveLength(0);
301+
await submit.click();
302+
await expect(host.getByRole('heading', { name: 'Thanks for your feedback!' })).toBeVisible();
303+
304+
expect(submissions).toHaveLength(1);
305+
expect(submissions[0]).toMatchObject({
306+
kind: 'bugdrop.variant-submission',
307+
schemaVersion: 1,
308+
variantId: 'next-integration-poll',
309+
issue: {
310+
title: 'Integration vote — onedrive-stable',
311+
classification: 'feature',
312+
sections: [
313+
{ heading: 'Choice', value: '<OneDrive>', format: 'text' },
314+
{ heading: 'Detail', value: 'Please add sync.', format: 'text' },
315+
],
316+
},
317+
});
318+
319+
await page.evaluate(() => (window as Window & { __poll?: { reset(): void } }).__poll?.reset());
320+
await expect(oneDrive).not.toBeChecked();
321+
await expect(host.getByRole('textbox', { name: 'Optional detail' })).toHaveValue('');
322+
const box = group.getByRole('radio', { name: 'Box Secure content cloud' });
323+
await box.click();
324+
expect(submissions).toHaveLength(1);
325+
await submit.click();
326+
expect(submissions).toHaveLength(2);
327+
expect(submissions[1]).toMatchObject({
328+
issue: {
329+
title: 'Integration vote — box-stable',
330+
sections: [{ heading: 'Choice', value: 'Box', format: 'text' }],
331+
},
332+
});
333+
expect(submissions[1]?.submissionId).not.toBe(submissions[0]?.submissionId);
334+
335+
await page.evaluate(() =>
336+
(window as Window & { __poll?: { unmount(): void } }).__poll?.unmount()
337+
);
338+
await expect(host).toHaveCount(0);
339+
});
225340
});

0 commit comments

Comments
 (0)