Skip to content

Commit 38710da

Browse files
enf0rc3claude
andcommitted
Switch the language control to a native select
The menu was a <details> with a hand-built option list, and eighteen of its lines re-implemented Escape-to-close and click-away-to-close. A <select> comes with those, plus keyboard navigation, focus handling and the mobile picker. code-blocks.js drops from 256 lines to 201, and the CSS loses the popup panel along with it. The trigger still matches the design. The list it opens is the browser's, so that part no longer matches the Figma panel: worth Mandy's eye before this merges. The caret is a mask on a wrapper span, since a <select> renders no pseudo-element of its own and a background image cannot follow the theme. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5b4f030 commit 38710da

4 files changed

Lines changed: 73 additions & 138 deletions

File tree

src/assets/icons/caret-down.svg

Lines changed: 4 additions & 0 deletions
Loading

src/scripts/modules/code-blocks.js

Lines changed: 23 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,6 @@ import { copyOnClick } from './copy-button.js';
88
/** Taller than this and the block collapses until it is clicked. */
99
const COLLAPSE_HEIGHT = 500;
1010

11-
/**
12-
* @param {string} tag
13-
* @param {string} className
14-
* @param {string} [text]
15-
*/
16-
function el(tag, className, text) {
17-
const node = document.createElement(tag);
18-
node.className = className;
19-
if (text) node.textContent = text;
20-
return node;
21-
}
22-
2311
/**
2412
* @param {HTMLElement} button
2513
*/
@@ -33,90 +21,47 @@ function visibleCode(button) {
3321
return code?.textContent ?? null;
3422
}
3523

36-
/* Language menu ---------------------------------------------------------- */
24+
/* Language switcher ------------------------------------------------------ */
3725

3826
/**
39-
* Swaps the static language text for a menu over the block's panels.
27+
* A <select>, so the keyboard handling, the dismissal and the mobile picker are
28+
* the browser's rather than ours.
4029
*
4130
* @param {HTMLElement} block
4231
* @param {{ name: string, label: string }[]} entries
4332
*/
44-
function addLanguageMenu(block, entries) {
33+
function addLanguageSelect(block, entries) {
4534
const panels = Array.from(qsa('.code-block__panel', block));
4635
const label = qs('.code-block__label', block);
4736

48-
const menu = document.createElement('details');
49-
menu.className = 'code-block__languages';
50-
51-
const trigger = document.createElement('summary');
52-
trigger.className = 'code-block__language-trigger btn btn--small';
53-
54-
const caret = el('i', 'fa-solid fa-caret-down btn__icon');
55-
caret.setAttribute('aria-hidden', 'true');
56-
57-
const triggerLabel = el('span', 'btn__label', entries[0].name);
58-
trigger.append(triggerLabel, caret);
37+
const select = document.createElement('select');
38+
select.className = 'code-block__language-select btn btn--small';
39+
select.setAttribute('aria-label', 'Language');
5940

60-
// The visible text alone would name the control "PowerShell", which says
61-
// nothing about it being a control.
62-
const nameTrigger = (name) =>
63-
trigger.setAttribute('aria-label', `Language: ${name}. Change language`);
41+
entries.forEach((entry, index) => {
42+
const option = document.createElement('option');
43+
option.value = String(index);
44+
option.textContent = entry.name;
45+
select.appendChild(option);
46+
});
6447

65-
const select = (index) => {
48+
const show = () => {
49+
const index = select.selectedIndex;
6650
panels.forEach((panel, i) => (panel.hidden = i !== index));
6751
label.textContent = entries[index].label;
6852
label.hidden = !entries[index].label;
6953
measure(block);
7054
};
7155

72-
const options = el('ul', 'code-block__language-options');
73-
entries.forEach((entry, index) => {
74-
const option = document.createElement('button');
75-
option.type = 'button';
76-
option.className = 'code-block__language-option';
77-
option.textContent = entry.name;
78-
option.setAttribute('aria-pressed', index === 0 ? 'true' : 'false');
56+
select.addEventListener('change', show);
7957

80-
option.addEventListener('click', () => {
81-
triggerLabel.textContent = entry.name;
82-
nameTrigger(entry.name);
83-
qsa('.code-block__language-option', options).forEach((other) =>
84-
other.setAttribute('aria-pressed', String(other === option))
85-
);
86-
menu.open = false;
87-
select(index);
88-
});
89-
90-
const item = document.createElement('li');
91-
item.appendChild(option);
92-
options.appendChild(item);
93-
});
94-
95-
menu.append(trigger, options);
96-
addMenuListeners(menu, trigger);
97-
98-
nameTrigger(entries[0].name);
99-
qs('.code-block__language', block).replaceWith(menu);
100-
select(0);
101-
}
58+
// Wrapped, because a <select> renders no pseudo-element to hang the caret on.
59+
const switcher = document.createElement('span');
60+
switcher.className = 'code-block__language-switcher';
61+
switcher.appendChild(select);
10262

103-
/**
104-
* @param {HTMLDetailsElement} menu
105-
* @param {HTMLElement} trigger
106-
*/
107-
function addMenuListeners(menu, trigger) {
108-
menu.addEventListener('keydown', (event) => {
109-
if (!menu.open || event.key !== 'Escape') return;
110-
event.preventDefault();
111-
menu.open = false;
112-
trigger.focus();
113-
});
114-
115-
document.addEventListener('click', (event) => {
116-
if (!menu.open) return;
117-
if (event.target instanceof Node && menu.contains(event.target)) return;
118-
menu.open = false;
119-
});
63+
qs('.code-block__language', block).replaceWith(switcher);
64+
show();
12065
}
12166

12267
/**
@@ -170,7 +115,7 @@ function enhanceGroups() {
170115
participants[0].replaceWith(host);
171116
participants.forEach((details) => details.remove());
172117

173-
addLanguageMenu(host, entries);
118+
addLanguageSelect(host, entries);
174119
});
175120
}
176121

src/styles/main.css

Lines changed: 22 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2783,63 +2783,35 @@ a[data-youtube] {
27832783
white-space: nowrap;
27842784
}
27852785

2786-
/* The menu, when the block was written in several languages */
2787-
.code-block__languages {
2786+
/* The switcher, when the block was written in several languages. Styled as the
2787+
design's button; the option list it opens belongs to the browser. */
2788+
.code-block__language-switcher {
27882789
position: relative;
2790+
display: inline-flex;
2791+
align-items: center;
27892792
}
27902793

2791-
.code-block__language-trigger {
2792-
list-style: none;
2794+
/* Qualified to outrank the `padding` shorthand `.btn` sets further down */
2795+
.code-block .code-block__language-select {
2796+
padding-inline-end: calc(var(--space16) + var(--space8));
27932797
cursor: pointer;
2798+
appearance: none;
2799+
-webkit-appearance: none;
27942800
}
27952801

2796-
/* Qualified to outrank the global rule that styles every <summary> as a link */
2797-
.code-block .code-block__language-trigger,
2798-
.code-block .code-block__language-trigger > * {
2799-
color: var(--colorTextPrimary);
2800-
text-decoration: none;
2801-
}
2802-
2803-
.code-block__language-trigger::-webkit-details-marker {
2804-
display: none;
2805-
}
2806-
2807-
/* Qualified to outrank `.page-content ul`, which indents every list */
2808-
.code-block .code-block__language-options {
2802+
/* On the wrapper, because a <select> renders no pseudo-elements of its own.
2803+
A mask so the caret takes the icon color in both themes. */
2804+
.code-block__language-switcher::after {
2805+
content: '';
28092806
position: absolute;
2810-
inset-inline-end: 0;
2811-
inset-block-start: calc(100% + var(--space4));
2812-
z-index: 2;
2813-
min-width: 100%;
2814-
margin: 0;
2815-
padding: var(--space4);
2816-
border: var(--borderWidth1) solid var(--colorBorderPrimary);
2817-
border-radius: var(--borderRadiusSmall);
2818-
background: var(--colorBackgroundPrimaryDefault);
2819-
box-shadow: var(--octo-shadow-standard-box);
2820-
list-style: none;
2821-
}
2822-
2823-
.code-block__language-option {
2824-
display: block;
2825-
width: 100%;
2826-
padding: var(--space4) var(--space8);
2827-
border: 0;
2828-
border-radius: var(--borderRadiusSmall);
2829-
background: transparent;
2830-
color: var(--colorTextPrimary);
2831-
font: var(--textBodyRegularMedium);
2832-
text-align: start;
2833-
white-space: nowrap;
2834-
cursor: pointer;
2835-
}
2836-
2837-
.code-block__language-option:is(:hover, :focus-visible) {
2838-
background: var(--colorBackgroundPrimaryHover);
2839-
}
2840-
2841-
.code-block__language-option[aria-pressed='true'] {
2842-
font: var(--textBodyBoldMedium);
2807+
inset-inline-end: var(--space6);
2808+
top: 50%;
2809+
translate: 0 -50%;
2810+
width: 1.25rem;
2811+
height: 1.25rem;
2812+
background-color: var(--colorIconPrimary);
2813+
mask: url('../assets/icons/caret-down.svg') center / contain no-repeat;
2814+
pointer-events: none;
28432815
}
28442816

28452817
/* --code-block-height is measured by code-blocks.js: max-height has to resolve

tests/code-block.spec.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ test.describe('code block, no JavaScript', () => {
4040
}) => {
4141
await page.goto(GROUPED);
4242

43-
// No menu to switch with, so each language keeps its own block
43+
// Nothing to switch with, so each language keeps its own block
4444
await expect(page.locator('.code-block')).toHaveCount(2);
45-
await expect(page.locator('.code-block__languages')).toHaveCount(0);
45+
await expect(page.locator('.code-block__language-select')).toHaveCount(0);
4646
});
4747
});
4848

@@ -87,29 +87,43 @@ test.describe('code block', () => {
8787
});
8888
});
8989

90-
test('turns a code-only details group into one block with a language menu', async ({
90+
test('turns a code-only details group into one block with a language select', async ({
9191
page,
9292
}) => {
9393
await page.goto(GROUPED);
9494

9595
const block = page.locator('.code-block').first();
96-
const trigger = block.locator('.code-block__language-trigger');
97-
await expect(trigger).toHaveText('PowerShell');
96+
const select = block.locator('.code-block__language-select');
97+
await expect(select).toHaveValue('0');
98+
expect(await select.locator('option').allTextContents()).toEqual([
99+
'PowerShell',
100+
'C#',
101+
]);
98102

99103
// Only the selected language is on the page
100104
await expect(block.locator('.code-block__panel:visible')).toHaveCount(1);
101105
await expect(block).toContainText('$repository.Machines.Modify');
102106

103-
await trigger.click();
104-
await block
105-
.locator('.code-block__language-option', { hasText: 'C#' })
106-
.click();
107+
await select.selectOption({ label: 'C#' });
107108

108-
await expect(trigger).toHaveText('C#');
109109
await expect(block).toContainText('repository.Machines.Modify(machine)');
110110
await expect(block.locator('.code-block__panel:visible')).toHaveCount(1);
111111
});
112112

113+
test('the select is reachable and operable from the keyboard', async ({
114+
page,
115+
}) => {
116+
await page.goto(GROUPED);
117+
118+
const select = page.locator('.code-block__language-select').first();
119+
await select.focus();
120+
await expect(select).toBeFocused();
121+
122+
// Free with <select>; the old menu hand-rolled all of this
123+
await select.press('ArrowDown');
124+
await expect(select).toHaveValue('1');
125+
});
126+
113127
test('leaves a group holding more than code as a tab list', async ({
114128
page,
115129
}) => {

0 commit comments

Comments
 (0)