Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions test_cases/listbox-picker/examples/bad-div-dropdown.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Style picker — anti-pattern: div dropdown, no ARIA semantics</title>
<script id="a11y-assertions" type="application/json">{
"assertions": {
"Picker trigger exposes popup state (aria-haspopup and aria-expanded)": "fail",
"Open menu uses role=listbox with role=option children": "fail",
"Each option has an accessible name": "pass",
"Options expose selection state with aria-selected": "fail",
"Arrow keys move the active option": "fail",
"Escape closes the listbox": "fail"
}
}</script>
</head>
<body>
<div class="style-picker">
<span>Text style</span>
<!-- ANTI-PATTERN: trigger has no aria-haspopup/aria-expanded; the menu is a
set of plain divs with no listbox/option roles, no aria-selected, and
mouse-only interaction (no arrow keys, no Escape). -->
<button type="button" id="trigger2">Normal</button>
<div id="menu2" style="display:none"></div>
</div>

<button type="submit">Submit</button>

<script>
const OPTIONS = ['Normal', 'Heading 1', 'Heading 2', 'Quote'];
const trigger = document.getElementById('trigger2');
const menu = document.getElementById('menu2');

OPTIONS.forEach((label) => {
const item = document.createElement('div');
item.className = 'menu-item';
item.textContent = label;
item.addEventListener('click', () => {
trigger.textContent = label;
menu.style.display = 'none';
});
menu.appendChild(item);
});

trigger.addEventListener('click', () => {
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
});
</script>
</body>
</html>
90 changes: 90 additions & 0 deletions test_cases/listbox-picker/examples/good-listbox-aria.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Style picker — conformant ARIA listbox</title>
<script id="a11y-assertions" type="application/json">{
"assertions": {
"Picker trigger exposes popup state (aria-haspopup and aria-expanded)": "pass",
"Open menu uses role=listbox with role=option children": "pass",
"Each option has an accessible name": "pass",
"Options expose selection state with aria-selected": "pass",
"Arrow keys move the active option": "pass",
"Escape closes the listbox": "pass"
}
}</script>
</head>
<body>
<div class="style-picker">
<span id="picker-label">Text style</span>
<button type="button" id="trigger" aria-haspopup="listbox" aria-expanded="false"
aria-labelledby="picker-label trigger">Normal</button>
<div role="listbox" id="lb" tabindex="-1" aria-labelledby="picker-label" hidden></div>
</div>

<button type="submit">Submit</button>

<script>
const OPTIONS = ['Normal', 'Heading 1', 'Heading 2', 'Quote'];
const trigger = document.getElementById('trigger');
const lb = document.getElementById('lb');
let selected = 0;

OPTIONS.forEach((label, i) => {
const opt = document.createElement('div');
opt.setAttribute('role', 'option');
opt.setAttribute('tabindex', '-1');
opt.setAttribute('aria-selected', i === selected ? 'true' : 'false');
opt.id = 'opt-' + i;
opt.textContent = label;
lb.appendChild(opt);
});

const options = () => Array.from(lb.querySelectorAll('[role="option"]'));

function open() {
lb.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
const sel = options()[selected] || options()[0];
sel.setAttribute('tabindex', '0');
sel.focus();
}
function close() {
lb.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
function choose(i) {
options().forEach((o, j) => o.setAttribute('aria-selected', j === i ? 'true' : 'false'));
selected = i;
trigger.textContent = OPTIONS[i];
}

trigger.addEventListener('click', () => {
if (trigger.getAttribute('aria-expanded') === 'true') close();
else open();
});

lb.addEventListener('click', (e) => {
const opt = e.target.closest('[role="option"]');
if (!opt) return;
choose(options().indexOf(opt));
close();
});

lb.addEventListener('keydown', (e) => {
const all = options();
let i = all.indexOf(document.activeElement);
if (e.key === 'ArrowDown') { e.preventDefault(); i = i < 0 ? 0 : Math.min(i + 1, all.length - 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); i = i <= 0 ? 0 : i - 1; }
else if (e.key === 'Escape') { e.preventDefault(); close(); return; }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (i >= 0) { choose(i); close(); } return; }
else return;
all.forEach((o) => o.setAttribute('tabindex', '-1'));
all[i].setAttribute('tabindex', '0');
all[i].focus();
});
</script>
</body>
</html>
8 changes: 8 additions & 0 deletions test_cases/listbox-picker/prompt.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: Listbox Picker
base_prompt: |
Build a page with a custom dropdown (do not use a native <select> element) for choosing a text style. A button shows the current choice and opens a menu listing four options: "Normal", "Heading 1", "Heading 2", and "Quote". The user can open the menu and pick one option with the mouse or the keyboard, and the chosen style is shown on the button.
common_requirements:
- Wrap the control in a div with class "style-picker".
- Do not use a native <select> element.
- Include a submit button.
dimensions:
185 changes: 185 additions & 0 deletions test_cases/listbox-picker/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Listbox picker accessibility assertions.
*
* Custom dropdown "picker" menus (font/style selectors, etc.) frequently expose
* no ARIA semantics: the popup is not a listbox, items are not options, the
* selected item has no aria-selected, the trigger does not announce open/closed,
* and there is no keyboard support. axe-core does not flag a div-based dropdown
* that "looks" interactive, so these are checked here.
*
* Grounded in the "haspopup-missing-aria-expanded", "listbox-missing-options",
* and "option-missing-aria-selected" anti-patterns (WAI-ARIA 1.2;
* WCAG SC 4.1.2; Class III — Widget Role Contract Violation).
*/

const normalizeText = (value) => (value || '').toString().replace(/[\s ]+/g, ' ').trim();

const scopeSelector = '.style-picker';

// Click a non-submit trigger to open the menu; returns nothing.
const openPicker = async (page) => {
const triggers = page.locator(scopeSelector + ' button, ' + scopeSelector + ' [role="button"], ' + scopeSelector + ' [aria-haspopup]');
const count = await triggers.count().catch(() => 0);
for (let i = 0; i < count; i += 1) {
const el = triggers.nth(i);
const text = normalizeText(await el.innerText().catch(() => ''));
const type = (await el.getAttribute('type').catch(() => '')) || '';
if (/submit/i.test(text) || type === 'submit') continue;
await el.click().catch(() => {});
await page.waitForTimeout(50);
break;
}
};

// Collect option-like items inside the picker: role="option" first, else the
// menu's clickable children. Tags each with [data-arr-opt-index].
const collectOptions = async (page) =>
page.evaluate((sel) => {
const scope = document.querySelector(sel) || document.body;
let els = Array.from(scope.querySelectorAll('[role="option"]'));
if (els.length === 0) {
// Fallback: clickable menu items (so we can still report missing roles).
const menu = scope.querySelector('[role="listbox"], [role="menu"], ul, .menu, .options, .dropdown') || scope;
els = Array.from(menu.querySelectorAll('li, [role="menuitem"], button, a, [tabindex]')).filter(
(el) => (el.textContent || '').trim().length > 0,
);
}
return els.map((el, i) => {
el.setAttribute('data-arr-opt-index', String(i));
let name = el.getAttribute('aria-label') || '';
if (!name) name = el.textContent || '';
const container = el.closest('[role="listbox"]');
return {
index: i,
name: name.replace(/[\s ]+/g, ' ').trim(),
role: el.getAttribute('role') || el.tagName.toLowerCase(),
ariaSelected: el.getAttribute('aria-selected'),
inListbox: Boolean(container),
tabindex: el.getAttribute('tabindex'),
};
});
}, scopeSelector);

const getTrigger = async (page) =>
page.evaluate((sel) => {
const scope = document.querySelector(sel) || document.body;
const buttons = Array.from(scope.querySelectorAll('button, [role="button"], [aria-haspopup]'));
const trigger = buttons.find((b) => (b.getAttribute('type') || '') !== 'submit' && !/submit/i.test(b.textContent || ''));
if (!trigger) return null;
return {
ariaHaspopup: trigger.getAttribute('aria-haspopup'),
ariaExpanded: trigger.getAttribute('aria-expanded'),
};
}, scopeSelector);

module.exports.run = async ({ page, assert, utils }) => {
const reload = async () => {
if (utils && typeof utils.reload === 'function') await utils.reload();
else await page.reload();
await page.waitForTimeout(50);
};

await openPicker(page);

await assert('Picker trigger exposes popup state (aria-haspopup and aria-expanded)', async () => {
const t = await getTrigger(page);
if (!t) return { pass: false, message: 'No picker trigger button found' };
if (!t.ariaHaspopup) {
return { pass: false, message: 'Trigger has no aria-haspopup; assistive technology cannot announce that it opens a popup' };
}
if (t.ariaExpanded === null || t.ariaExpanded === undefined) {
return { pass: false, message: 'Trigger has aria-haspopup but no aria-expanded; AT cannot announce whether the popup is open or closed' };
}
return { pass: true, message: 'Trigger exposes aria-haspopup and aria-expanded' };
});

await assert('Open menu uses role=listbox with role=option children', async () => {
const hasListbox = await page.locator(scopeSelector + ' [role="listbox"]').count().catch(() => 0);
const options = await collectOptions(page);
const realOptions = options.filter((o) => o.role === 'option' && o.inListbox);
if (hasListbox > 0 && realOptions.length > 0) {
return { pass: true, message: `Listbox with ${realOptions.length} option children` };
}
return {
pass: false,
message: 'Popup is not exposed as role="listbox" with role="option" children; screen readers cannot present it as a selectable list',
};
});

await assert('Each option has an accessible name', async () => {
const options = await collectOptions(page);
if (options.length === 0) return { pass: false, message: 'No options found in the menu' };
const unnamed = options.filter((o) => !normalizeText(o.name));
if (unnamed.length === 0) return { pass: true, message: 'All options have accessible names' };
return { pass: false, message: `${unnamed.length} option(s) have no accessible name` };
});

await assert('Options expose selection state with aria-selected', async () => {
const options = await collectOptions(page);
if (options.length === 0) return { pass: false, message: 'No options found in the menu' };
const withSelected = options.filter((o) => o.ariaSelected !== null && o.ariaSelected !== undefined);
if (withSelected.length === 0) {
return { pass: false, message: 'No option exposes aria-selected; AT cannot announce which option is current' };
}
const selectedTrue = options.filter((o) => String(o.ariaSelected) === 'true');
if (selectedTrue.length !== 1) {
return { pass: false, message: `Expected exactly one option with aria-selected="true"; found ${selectedTrue.length}` };
}
return { pass: true, message: 'Options expose aria-selected with exactly one selected' };
});

await assert('Arrow keys move the active option', async () => {
await reload();
await openPicker(page);
const options = await collectOptions(page);
if (options.length < 2) return { pass: false, message: 'Not enough options to test keyboard navigation' };

// The "active option" is whichever the widget tracks: the focused option
// (roving tabindex) or the listbox's aria-activedescendant.
const readActive = () =>
page.evaluate(() => {
const ae = document.activeElement;
if (!ae || !ae.getAttribute) return null;
const adesc = ae.getAttribute('aria-activedescendant');
if (adesc) return 'ad:' + adesc;
const oi = ae.getAttribute('data-arr-opt-index');
return oi !== null && oi !== undefined ? 'oi:' + oi : null;
});

let before = await readActive();
if (!before) {
// Nothing focused on open — focus the first option to start.
await page.locator('[data-arr-opt-index="0"]').focus().catch(() => {});
before = await readActive();
}
await page.keyboard.press('ArrowDown').catch(() => {});
await page.waitForTimeout(30);
const after = await readActive();
if (after && after !== before) {
return { pass: true, message: 'Arrow keys move the active option' };
}
return { pass: false, message: 'Arrow keys did not move the active option; keyboard users cannot navigate the menu' };
});

await assert('Escape closes the listbox', async () => {
await reload();
await openPicker(page);
const t1 = await getTrigger(page);
const openBefore = t1 && String(t1.ariaExpanded) === 'true';
const visibleBefore = (await page.locator(scopeSelector + ' [role="listbox"]:visible').count().catch(() => 0)) > 0;
if (!openBefore && !visibleBefore) {
return { pass: false, message: 'Menu did not appear open after activating the trigger, so close-on-Escape cannot be verified' };
}
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(30);
const t2 = await getTrigger(page);
const expandedAfter = t2 ? String(t2.ariaExpanded) === 'true' : false;
const visibleAfter = (await page.locator(scopeSelector + ' [role="listbox"]:visible').count().catch(() => 0)) > 0;
if (!expandedAfter && !visibleAfter) {
return { pass: true, message: 'Escape closes the listbox' };
}
return { pass: false, message: 'Escape did not close the listbox (aria-expanded stayed true / listbox stayed visible)' };
});

return {};
};