@@ -251,6 +251,16 @@ const defaultOption = highlightedOptions[defaultIndex];
251251 background: rgba(255, 255, 255, 0.1);
252252 font-weight: 500;
253253 }
254+
255+ .dropdown-option:focus {
256+ outline: none;
257+ background: rgba(255, 255, 255, 0.12);
258+ }
259+
260+ .dropdown-option:focus-visible {
261+ outline: 2px solid rgba(255, 255, 255, 0.4);
262+ outline-offset: -2px;
263+ }
254264</style >
255265
256266<script is:inline >
@@ -263,52 +273,157 @@ const defaultOption = highlightedOptions[defaultIndex];
263273 const commandText = selector.querySelector('.command-text');
264274 const copyBtn = selector.querySelector('.copy-btn');
265275 const options = selector.querySelectorAll('.dropdown-option');
276+ const optionsArray = Array.from(options);
266277
267278 if (!trigger || !installBox) return;
268279
269- // Toggle dropdown
270- trigger.addEventListener('click', function(e) {
271- e.stopPropagation();
272- const isOpen = installBox.classList.contains('open');
280+ /**
281+ * Selects an option: updates UI, closes dropdown, returns focus to trigger.
282+ */
283+ function selectOption(option) {
284+ const newLabel = option.getAttribute('data-label');
285+ const newCommand = option.getAttribute('data-command');
286+ const newHighlighted = option.getAttribute('data-highlighted');
287+
288+ if (label && newLabel) label.textContent = newLabel;
289+ if (commandText && newHighlighted) {
290+ commandText.innerHTML = newHighlighted;
291+ }
292+ if (commandText && newCommand) {
293+ commandText.setAttribute('data-command', newCommand);
294+ }
295+ if (copyBtn && newCommand) {
296+ copyBtn.setAttribute('data-command', newCommand);
297+ }
273298
274- // Close all other dropdowns
299+ // Update selected state
300+ options.forEach(function(opt) {
301+ opt.classList.remove('selected');
302+ opt.setAttribute('aria-selected', 'false');
303+ });
304+ option.classList.add('selected');
305+ option.setAttribute('aria-selected', 'true');
306+
307+ // Close dropdown and return focus
308+ installBox.classList.remove('open');
309+ trigger.setAttribute('aria-expanded', 'false');
310+ trigger.focus();
311+ }
312+
313+ /**
314+ * Opens the dropdown and focuses the selected or first option.
315+ */
316+ function openDropdown() {
317+ // Close all other dropdowns first
275318 document.querySelectorAll('.install-box.open').forEach(function(box) {
276319 if (box !== installBox) box.classList.remove('open');
277320 });
278321
279- installBox.classList.toggle('open', !isOpen);
280- trigger.setAttribute('aria-expanded', (!isOpen).toString());
322+ installBox.classList.add('open');
323+ trigger.setAttribute('aria-expanded', 'true');
324+
325+ // Focus the currently selected option, or first option
326+ const selectedOption = selector.querySelector('.dropdown-option.selected') || options[0];
327+ if (selectedOption) selectedOption.focus();
328+ }
329+
330+ /**
331+ * Closes the dropdown and returns focus to trigger.
332+ */
333+ function closeDropdown() {
334+ installBox.classList.remove('open');
335+ trigger.setAttribute('aria-expanded', 'false');
336+ trigger.focus();
337+ }
338+
339+ // Toggle dropdown on click
340+ trigger.addEventListener('click', function(e) {
341+ e.stopPropagation();
342+ const isOpen = installBox.classList.contains('open');
343+
344+ if (isOpen) {
345+ closeDropdown();
346+ } else {
347+ openDropdown();
348+ }
281349 });
282350
283- // Handle option selection
284- options.forEach(function(option) {
351+ // Keyboard navigation on trigger
352+ trigger.addEventListener('keydown', function(e) {
353+ const isOpen = installBox.classList.contains('open');
354+
355+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
356+ e.preventDefault();
357+ if (!isOpen) {
358+ openDropdown();
359+ } else {
360+ // Focus first or last option based on direction
361+ const targetOption = e.key === 'ArrowDown' ? options[0] : options[options.length - 1];
362+ if (targetOption) targetOption.focus();
363+ }
364+ }
365+
366+ if (e.key === 'Enter' || e.key === ' ') {
367+ e.preventDefault();
368+ if (!isOpen) {
369+ openDropdown();
370+ }
371+ }
372+
373+ if (e.key === 'Home' && isOpen) {
374+ e.preventDefault();
375+ if (options[0]) options[0].focus();
376+ }
377+
378+ if (e.key === 'End' && isOpen) {
379+ e.preventDefault();
380+ if (options[options.length - 1]) options[options.length - 1].focus();
381+ }
382+ });
383+
384+ // Handle option click and keyboard navigation
385+ options.forEach(function(option, index) {
285386 option.addEventListener('click', function() {
286- const newLabel = option.getAttribute('data-label');
287- const newCommand = option.getAttribute('data-command');
288- const newHighlighted = option.getAttribute('data-highlighted');
387+ selectOption(option);
388+ });
389+
390+ option.addEventListener('keydown', function(e) {
391+ if (e.key === 'ArrowDown') {
392+ e.preventDefault();
393+ const nextIndex = index < optionsArray.length - 1 ? index + 1 : 0;
394+ optionsArray[nextIndex].focus();
395+ }
289396
290- if (label && newLabel) label.textContent = newLabel;
291- if (commandText && newHighlighted) {
292- commandText.innerHTML = newHighlighted;
397+ if (e.key === 'ArrowUp') {
398+ e.preventDefault();
399+ const prevIndex = index > 0 ? index - 1 : optionsArray.length - 1;
400+ optionsArray[prevIndex].focus();
293401 }
294- if (commandText && newCommand) {
295- commandText.setAttribute('data-command', newCommand);
402+
403+ if (e.key === 'Home') {
404+ e.preventDefault();
405+ options[0].focus();
406+ }
407+
408+ if (e.key === 'End') {
409+ e.preventDefault();
410+ options[options.length - 1].focus();
296411 }
297- if (copyBtn && newCommand) {
298- copyBtn.setAttribute('data-command', newCommand);
412+
413+ if (e.key === 'Enter' || e.key === ' ') {
414+ e.preventDefault();
415+ selectOption(option);
299416 }
300417
301- // Update selected state
302- options.forEach(function(opt) {
303- opt.classList.remove('selected');
304- opt.setAttribute('aria-selected', 'false');
305- });
306- option.classList.add('selected');
307- option.setAttribute('aria-selected', 'true');
418+ if (e.key === 'Escape') {
419+ e.preventDefault();
420+ closeDropdown();
421+ }
308422
309- // Close dropdown
310- installBox.classList.remove('open');
311- trigger.setAttribute('aria-expanded', 'false');
423+ if (e.key === 'Tab') {
424+ // Close dropdown when tabbing away
425+ closeDropdown();
426+ }
312427 });
313428 });
314429
@@ -336,13 +451,16 @@ const defaultOption = highlightedOptions[defaultIndex];
336451 }
337452 });
338453
339- // Close on escape
454+ // Close on escape (global handler for when focus is elsewhere)
340455 document.addEventListener('keydown', function(e) {
341456 if (e.key === 'Escape') {
342457 document.querySelectorAll('.install-box.open').forEach(function(box) {
343458 box.classList.remove('open');
344459 const trigger = box.querySelector('.dropdown-trigger');
345- if (trigger) trigger.setAttribute('aria-expanded', 'false');
460+ if (trigger) {
461+ trigger.setAttribute('aria-expanded', 'false');
462+ trigger.focus();
463+ }
346464 });
347465 }
348466 });
0 commit comments