fix(ui): add keyboard accessibility to code controls menu

Replace clickable <span> and <li> elements with proper <button> elements
and ARIA roles for keyboard and assistive-technology access:

- Toggle: <span> → <button> with aria-label and aria-expanded
- Menu items: <li> → <li role="none"><button role="menuitem">
- Menu: <ul> gets role="menu"
- SCSS: add :focus-visible styles, reset button defaults
- JS: toggle handler updates aria-expanded on open/close

Also fix tests to match actual behavior:
- Remove bogus "close other menus" test that manually closed first menu
  before opening second (tested nothing)
- Remove duplicate "close on re-click" test (same as outside-click)
- Add "keep menu open when copy is clicked" (stopPropagation behavior)
- Add accessibility markup validation test
pull/6955/head
Jason Stirnaman 2026-03-16 19:29:35 -05:00
parent 60e29554dd
commit 95f9fac6c4
3 changed files with 59 additions and 51 deletions

View File

@ -7,11 +7,11 @@ function initialize() {
var appendHTML = `
<div class="code-controls">
<span class="code-controls-toggle"><span class='cf-icon More'></span></span>
<ul class="code-control-options">
<li class='copy-code'><span class='cf-icon Duplicate_New'></span> <span class="message">Copy</span></li>
<li class='ask-ai-code'><span class='cf-icon Chat'></span> Ask AI</li>
<li class='fullscreen-toggle'><span class='cf-icon ExpandB'></span> Fill window</li>
<button class="code-controls-toggle" aria-label="Code block options" aria-expanded="false"><span class='cf-icon More'></span></button>
<ul class="code-control-options" role="menu">
<li role="none"><button role="menuitem" class='copy-code'><span class='cf-icon Duplicate_New'></span> <span class="message">Copy</span></button></li>
<li role="none"><button role="menuitem" class='ask-ai-code'><span class='cf-icon Chat'></span> Ask AI</button></li>
<li role="none"><button role="menuitem" class='fullscreen-toggle'><span class='cf-icon ExpandB'></span> Fill window</button></li>
</ul>
</div>
`;
@ -28,12 +28,17 @@ function initialize() {
// Click outside of the code-controls to close them
$(document).click(function () {
$('.code-controls').removeClass('open');
$('.code-controls.open').each(function () {
$(this).removeClass('open');
$(this).find('.code-controls-toggle').attr('aria-expanded', 'false');
});
});
// Click the code controls toggle to open code controls
$('.code-controls-toggle').click(function () {
$(this).parent('.code-controls').toggleClass('open');
var $controls = $(this).parent('.code-controls');
var isOpen = $controls.toggleClass('open').hasClass('open');
$(this).attr('aria-expanded', String(isOpen));
});
// Stop event propagation for clicks inside of the code-controls div

View File

@ -16,10 +16,12 @@
opacity: .5;
transition: opacity .2s;
border-radius: $radius;
border: none;
background: none;
line-height: 0;
cursor: pointer;
cursor: pointer;
&:hover {
&:hover, &:focus-visible {
opacity: 1;
background-color: rgba($article-text, .1);
backdrop-filter: blur(15px);
@ -35,16 +37,23 @@
backdrop-filter: blur(15px);
display: none;
li {
button {
display: block;
width: 100%;
text-align: left;
margin: 0;
padding: .4rem .5rem .6rem;
border: none;
background: none;
border-radius: $radius;
color: $article-bold;
font-size: .87rem;
line-height: 0;
cursor: pointer;
cursor: pointer;
&:hover {background-color: rgba($article-text, .07)}
&:hover, &:focus-visible {
background-color: rgba($article-text, .07);
}
.cf-icon {margin-right: .35rem;}
@ -67,6 +76,8 @@
}
}
}
li {margin: 0;}
}
&.open {

View File

@ -19,13 +19,13 @@
* - [x] Controls contain toggle, copy, Ask AI, and fullscreen items
* - [x] Menu is hidden by default (toggle visible)
* - [x] Ask AI is in the middle (2nd) position
* - [x] Controls use accessible markup (buttons, ARIA roles)
*
* Toggle Behavior:
* ----------------
* - [x] Clicking toggle opens menu (adds .open class)
* - [x] Clicking outside after opening with toggle closes menu
* - [x] Clicking outside closes menu
* - [x] Only one menu open at a time
* - [x] Copy button keeps menu open (stopPropagation)
*
* Copy to Clipboard:
* ------------------
@ -83,18 +83,38 @@ describe('Code Controls', function () {
cy.get('.article--content .codeblock')
.first()
.within(() => {
cy.get('.code-control-options li')
cy.get('.code-control-options button[role="menuitem"]')
.eq(0)
.should('have.class', 'copy-code');
cy.get('.code-control-options li')
cy.get('.code-control-options button[role="menuitem"]')
.eq(1)
.should('have.class', 'ask-ai-code');
cy.get('.code-control-options li')
cy.get('.code-control-options button[role="menuitem"]')
.eq(2)
.should('have.class', 'fullscreen-toggle');
});
});
it('should use accessible markup for controls', function () {
cy.get('.article--content .codeblock')
.first()
.within(() => {
// Toggle is a button with aria attributes
cy.get('.code-controls-toggle')
.should('have.attr', 'aria-label', 'Code block options')
.and('have.attr', 'aria-expanded', 'false');
// Menu has role="menu"
cy.get('.code-control-options').should('have.attr', 'role', 'menu');
// Menu items are buttons with role="menuitem"
cy.get('.code-control-options button[role="menuitem"]').should(
'have.length',
3
);
});
});
it('should show toggle and hide menu by default', function () {
cy.get('.article--content .code-controls')
.first()
@ -121,19 +141,6 @@ describe('Code Controls', function () {
.should('be.visible');
});
it('should close menu when clicking outside after opening with toggle', function () {
cy.get('.article--content .code-controls-toggle').first().click();
cy.get('.article--content .code-controls')
.first()
.should('have.class', 'open');
// Click outside to close (document click handler removes .open)
cy.get('.article--content h2').first().click({ force: true });
cy.get('.article--content .code-controls')
.first()
.should('not.have.class', 'open');
});
it('should close menu when clicking outside', function () {
cy.get('.article--content .code-controls-toggle').first().click();
cy.get('.article--content .code-controls')
@ -147,32 +154,17 @@ describe('Code Controls', function () {
.should('not.have.class', 'open');
});
it('should close other menus when a new toggle is clicked', function () {
// Need at least two code blocks
cy.get('.article--content .codeblock').should('have.length.at.least', 2);
// Open first menu
cy.get('.article--content .code-controls-toggle').eq(0).click();
it('should keep menu open when copy is clicked', function () {
cy.get('.article--content .code-controls-toggle').first().click();
cy.get('.article--content .code-controls')
.eq(0)
.first()
.should('have.class', 'open');
// Click outside to close first (since toggle is hidden when open)
cy.get('.article--content h2').first().click({ force: true });
// Click copy — menu should stay open (stopPropagation)
cy.get('.article--content .copy-code').first().click();
cy.get('.article--content .code-controls')
.eq(0)
.should('not.have.class', 'open');
// Open second menu
cy.get('.article--content .code-controls-toggle').eq(1).click();
cy.get('.article--content .code-controls')
.eq(1)
.first()
.should('have.class', 'open');
// First should remain closed
cy.get('.article--content .code-controls')
.eq(0)
.should('not.have.class', 'open');
});
});