@use 'sprucecss/scss/spruce' as *;
.context-menu-demo {
align-items: start;
display: grid;
gap: spacer('l');
@include breakpoint('md') {
grid-template-columns: auto 1fr;
}
}
.btn-dropdown {
display: inline-block;
position: relative;
}
.context-menu {
--menu-inline-size: 15rem;
--menu-offset: #{spacer('xxs')};
@include clear-list;
background-color: color('surface');
border: config('border-width-base', $display) solid color('border');
border-radius: radius('md');
box-shadow: shadow('lg');
color: color('text');
font-size: config('font-size-sm', $typography);
inline-size: var(--menu-inline-size);
inset-block-start: calc(100% + var(--menu-offset));
max-inline-size: calc(100vi - #{spacer('m')});
padding: spacer('xxs');
position: absolute;
z-index: z-index('dropdown');
&[hidden] {
display: none;
}
@include motion-safe {
animation: context-menu-in 0.12s ease-out;
}
&--inline-start {
inset-inline-start: 0;
}
&--inline-end {
inset-inline-end: 0;
}
&--block-start {
inset-block: auto calc(100% + var(--menu-offset));
}
&--fixed {
inset-block-start: var(--menu-block, 0);
inset-inline-start: var(--menu-inline, 0);
position: fixed;
}
&--submenu {
inset-block-start: calc(#{spacer('xxs')} * -1);
inset-inline-start: calc(100% + #{spacer('xxs')});
&.context-menu--flip {
inset-inline: auto calc(100% + #{spacer('xxs')});
}
}
> li,
&__group > li {
margin: 0 !important;
}
&__group {
@include clear-list;
}
&__submenu-wrapper {
position: relative;
}
&__label {
color: color('muted');
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
padding: spacer('xs') spacer('s') spacer('xxs');
text-transform: uppercase;
}
&__separator {
border-block-start: config('border-width-base', $display) solid color('border');
margin-block: spacer('xxs') !important;
}
&__item {
@include clear-btn;
align-items: center;
border-radius: radius('sm');
color: color('heading');
display: flex;
gap: spacer('xs');
inline-size: 100%;
padding: spacer('xs') spacer('s');
text-align: start;
&:hover,
&:focus {
background-color: color-alpha('primary', 0.1);
color: color('primary');
outline: none;
}
&:focus-visible {
box-shadow: 0 0 0 2px color('focus-ring') inset;
}
&[aria-expanded='true'] {
background-color: color-alpha('primary', 0.1);
}
&[aria-disabled='true'] {
color: color('muted');
cursor: not-allowed;
opacity: 0.6;
&:hover,
&:focus {
background-color: transparent;
}
}
&[role='menuitemcheckbox'],
&[role='menuitemradio'] {
&::before {
content: '';
flex-shrink: 0;
visibility: hidden;
}
&[aria-checked='true']::before {
visibility: visible;
}
}
&[role='menuitemcheckbox']::before {
block-size: 0.35rem;
border-block-end: 2px solid currentcolor;
border-left: 2px solid currentcolor;
inline-size: 0.7rem;
margin: 0 0.15rem 0.2rem;
rotate: -45deg;
}
&[role='menuitemradio']::before {
background-color: currentcolor;
block-size: 0.5rem;
border-radius: 50%;
inline-size: 0.5rem;
margin: 0.25rem;
}
&--danger {
color: color('danger', 'alert');
&:hover,
&:focus {
background-color: color-alpha('danger', 0.1, 'alert');
color: color('danger', 'alert');
}
}
}
&__icon {
block-size: 1rem;
color: color('muted');
flex-shrink: 0;
inline-size: 1rem;
}
&__item:hover &__icon,
&__item:focus &__icon,
&__item--danger &__icon {
color: currentcolor;
}
&__text {
flex-grow: 1;
}
&__shortcut {
background-color: transparent;
border: 0;
box-shadow: none;
color: color('muted');
font-size: 0.75rem;
padding: 0;
}
&__chevron {
block-size: 0.9rem;
color: color('muted');
inline-size: 0.9rem;
}
}
[dir='rtl'] .context-menu__chevron {
scale: -1 1;
}
.context-menu-area {
align-content: center;
border: 2px dashed color('border-strong');
border-radius: radius('lg');
display: grid;
gap: spacer('xxs');
min-block-size: 12rem;
padding: spacer('m');
text-align: center;
&:focus-visible {
border-color: color('primary');
outline: 2px solid color('focus-ring');
outline-offset: 2px;
}
&__title {
color: color('heading');
font-weight: 700;
margin-block: 0;
}
&__help {
color: color('muted');
font-size: config('font-size-sm', $typography);
margin-block: 0;
}
}
@keyframes context-menu-in {
from {
opacity: 0;
translate: 0 -0.25rem;
}
}
<div class="context-menu-demo">
<div class="btn-dropdown" data-menu>
<button class="btn btn--primary" type="button" id="menu-add-block-button" aria-haspopup="menu" aria-expanded="false" aria-controls="menu-add-block" data-menu-trigger>
Add block
<svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
</button>
<ul class="context-menu context-menu--inline-start" id="menu-add-block" role="menu" aria-labelledby="menu-add-block-button" hidden data-menu-list>
<li class="context-menu__label" role="presentation">Content</li>
<li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></svg><span class="context-menu__text">Text</span><kbd class="context-menu__shortcut">T</kbd></button></li>
<li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></svg><span class="context-menu__text">Image</span><kbd class="context-menu__shortcut">I</kbd></button></li>
<li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg><span class="context-menu__text">Quote</span><kbd class="context-menu__shortcut">Q</kbd></button></li>
<li role="none" class="context-menu__submenu-wrapper">
<button class="context-menu__item" type="button" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="menu-embed" id="menu-embed-button" data-submenu-trigger><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg><span class="context-menu__text">Embed</span><svg class="context-menu__chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg></button>
<ul class="context-menu context-menu--submenu" id="menu-embed" role="menu" aria-labelledby="menu-embed-button" hidden data-menu-list>
<li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><span class="context-menu__text">YouTube video</span></button></li>
<li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><span class="context-menu__text">Vimeo video</span></button></li>
<li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><span class="context-menu__text">CodePen</span></button></li>
<li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1" aria-disabled="true"><span class="context-menu__text">Map</span></button></li>
</ul>
</li>
<li class="context-menu__separator" role="separator"></li>
<li class="context-menu__label" role="presentation">View</li>
<li role="none"><button class="context-menu__item" type="button" role="menuitemcheckbox" tabindex="-1" aria-checked="true"><span class="context-menu__text">Show outlines</span></button></li>
<li role="none"><button class="context-menu__item" type="button" role="menuitemcheckbox" tabindex="-1" aria-checked="false"><span class="context-menu__text">Snap to grid</span></button></li>
<li class="context-menu__separator" role="separator"></li>
<li role="none">
<ul class="context-menu__group" role="group" aria-label="Width">
<li role="none"><button class="context-menu__item" type="button" role="menuitemradio" tabindex="-1" aria-checked="false"><span class="context-menu__text">Narrow</span></button></li>
<li role="none"><button class="context-menu__item" type="button" role="menuitemradio" tabindex="-1" aria-checked="true"><span class="context-menu__text">Regular</span></button></li>
<li role="none"><button class="context-menu__item" type="button" role="menuitemradio" tabindex="-1" aria-checked="false"><span class="context-menu__text">Full width</span></button></li>
</ul>
</li>
</ul>
</div>
<div class="context-menu-area" tabindex="0" aria-describedby="context-menu-area-help" data-context-area data-menu>
<p class="context-menu-area__title">Right-click this area</p>
<p class="context-menu-area__help" id="context-menu-area-help">Or focus it and press <kbd>Shift</kbd> + <kbd>F10</kbd> (or the Menu key).</p>
<ul class="context-menu" id="menu-canvas" role="menu" aria-label="Block actions" hidden data-menu-list>
<li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg><span class="context-menu__text">Duplicate</span><kbd class="context-menu__shortcut">Ctrl D</kbd></button></li>
<li role="none"><button class="context-menu__item" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg><span class="context-menu__text">Copy link</span></button></li>
<li class="context-menu__separator" role="separator"></li>
<li role="none"><button class="context-menu__item context-menu__item--danger" type="button" role="menuitem" tabindex="-1"><svg class="context-menu__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg><span class="context-menu__text">Delete</span><kbd class="context-menu__shortcut">Del</kbd></button></li>
</ul>
</div>
<p class="sr-only" aria-live="polite" data-menu-status></p>
</div>
(() => {
const status = document.querySelector('[data-menu-status]');
const isRtl = (element) => getComputedStyle(element).direction === 'rtl';
const itemsOf = (menu) => [...menu.querySelectorAll(':scope > li > [role^="menuitem"], :scope > li > ul > li > [role^="menuitem"]')];
const place = (menu) => {
menu.classList.remove('context-menu--block-start', 'context-menu--flip');
const rect = menu.getBoundingClientRect();
if (menu.classList.contains('context-menu--submenu')) {
if (rect.right > window.innerWidth || rect.left < 0) menu.classList.add('context-menu--flip');
} else if (!menu.classList.contains('context-menu--fixed') && rect.bottom > window.innerHeight && rect.top > rect.height) {
menu.classList.add('context-menu--block-start');
}
};
const closeMenu = (menu, { restoreFocus = false } = {}) => {
if (!menu || menu.hidden) return;
menu.querySelectorAll('[data-menu-list]').forEach((submenu) => closeMenu(submenu));
menu.hidden = true;
const trigger = document.querySelector(`[aria-controls="${menu.id}"]`);
trigger?.setAttribute('aria-expanded', 'false');
if (restoreFocus) (trigger ?? menu.closest('[data-context-area]'))?.focus();
};
const openMenu = (menu, focus = 'first') => {
menu.hidden = false;
document.querySelector(`[aria-controls="${menu.id}"]`)?.setAttribute('aria-expanded', 'true');
place(menu);
const items = itemsOf(menu).filter((item) => item.getAttribute('aria-disabled') !== 'true');
(focus === 'last' ? items[items.length - 1] : items[0])?.focus();
};
const activate = (item) => {
if (item.getAttribute('aria-disabled') === 'true') return;
const role = item.getAttribute('role');
const label = item.querySelector('.context-menu__text').textContent;
if (item.hasAttribute('aria-haspopup')) {
openMenu(document.getElementById(item.getAttribute('aria-controls')));
return;
}
if (role === 'menuitemcheckbox') {
const checked = item.getAttribute('aria-checked') !== 'true';
item.setAttribute('aria-checked', String(checked));
status.textContent = `${label} ${checked ? 'on' : 'off'}`;
return;
}
if (role === 'menuitemradio') {
item.closest('[role="group"]').querySelectorAll('[role="menuitemradio"]').forEach((other) => other.setAttribute('aria-checked', String(other === item)));
status.textContent = `${label} selected`;
return;
}
status.textContent = `${label} chosen`;
item.dispatchEvent(new CustomEvent('menuselect', { bubbles: true, detail: label }));
closeMenu(item.closest('[data-menu]').querySelector(':scope > [data-menu-list]'), { restoreFocus: true });
};
document.querySelectorAll('[data-menu]').forEach((root) => {
const menu = root.querySelector(':scope > [data-menu-list]');
const trigger = root.querySelector(':scope > [data-menu-trigger]');
trigger?.addEventListener('click', () => (menu.hidden ? openMenu(menu) : closeMenu(menu)));
trigger?.addEventListener('keydown', (event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
openMenu(menu, event.key === 'ArrowUp' ? 'last' : 'first');
}
});
if (root.hasAttribute('data-context-area')) {
const openAt = (x, y) => {
menu.classList.add('context-menu--fixed');
menu.style.setProperty('--menu-block', `${y}px`);
menu.style.setProperty('--menu-inline', `${isRtl(root) ? window.innerWidth - x : x}px`);
openMenu(menu);
const rect = menu.getBoundingClientRect();
if (rect.bottom > window.innerHeight) menu.style.setProperty('--menu-block', `${Math.max(8, y - rect.height)}px`);
if (rect.right > window.innerWidth || rect.left < 0) menu.style.setProperty('--menu-inline', `${Math.max(8, (isRtl(root) ? window.innerWidth - x : x) - rect.width)}px`);
};
root.addEventListener('contextmenu', (event) => {
if (menu.contains(event.target)) return;
event.preventDefault();
openAt(event.clientX, event.clientY);
});
root.addEventListener('keydown', (event) => {
if (event.target === root && ((event.shiftKey && event.key === 'F10') || event.key === 'ContextMenu')) {
event.preventDefault();
const rect = root.getBoundingClientRect();
openAt(isRtl(root) ? rect.right - 16 : rect.left + 16, rect.top + 16);
}
});
}
root.addEventListener('click', (event) => {
const item = event.target.closest('[role^="menuitem"]');
if (item && root.contains(item)) activate(item);
});
root.addEventListener('keydown', (event) => {
const item = event.target.closest('[role^="menuitem"]');
if (!item) return;
const currentMenu = item.closest('[data-menu-list]');
const items = itemsOf(currentMenu);
const index = items.indexOf(item);
const forward = isRtl(root) ? 'ArrowLeft' : 'ArrowRight';
const backward = isRtl(root) ? 'ArrowRight' : 'ArrowLeft';
const isSubmenu = currentMenu.classList.contains('context-menu--submenu');
switch (event.key) {
case 'ArrowDown': items[(index + 1) % items.length].focus(); break;
case 'ArrowUp': items[(index - 1 + items.length) % items.length].focus(); break;
case 'Home': items[0].focus(); break;
case 'End': items[items.length - 1].focus(); break;
case 'Enter':
case ' ': activate(item); break;
case forward:
if (item.hasAttribute('aria-haspopup')) openMenu(document.getElementById(item.getAttribute('aria-controls')));
break;
case backward:
if (isSubmenu) { closeMenu(currentMenu); document.querySelector(`[aria-controls="${currentMenu.id}"]`).focus(); }
break;
case 'Escape':
if (isSubmenu) { closeMenu(currentMenu); document.querySelector(`[aria-controls="${currentMenu.id}"]`).focus(); } else { closeMenu(menu, { restoreFocus: true }); }
break;
case 'Tab': closeMenu(menu); return;
default: {
if (event.key.length !== 1 || !/\S/.test(event.key)) return;
const next = [...items.slice(index + 1), ...items.slice(0, index)].find((other) => other.textContent.trim().toLowerCase().startsWith(event.key.toLowerCase()));
next?.focus();
}
}
event.preventDefault();
});
root.querySelectorAll('[data-submenu-trigger]').forEach((submenuTrigger) => {
const wrapper = submenuTrigger.parentElement;
const submenu = document.getElementById(submenuTrigger.getAttribute('aria-controls'));
wrapper.addEventListener('pointerenter', () => { submenu.hidden = false; submenuTrigger.setAttribute('aria-expanded', 'true'); place(submenu); });
wrapper.addEventListener('pointerleave', () => closeMenu(submenu));
});
});
document.addEventListener('pointerdown', (event) => {
document.querySelectorAll('[data-menu] > [data-menu-list]:not([hidden])').forEach((menu) => {
if (!menu.closest('[data-menu]').contains(event.target) || (menu.classList.contains('context-menu--fixed') && !menu.contains(event.target))) closeMenu(menu);
});
});
document.addEventListener('scroll', () => {
document.querySelectorAll('.context-menu--fixed:not([hidden])').forEach((menu) => closeMenu(menu));
}, { capture: true, passive: true });
})();