Skip to content
Merged
112 changes: 112 additions & 0 deletions libs/core/src/components/pds-dropdown-menu/docs/pds-dropdown-menu.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,118 @@ Menu items can open links in a new tab using the `external` boolean prop. This d

>**Note:** You can also use the `target` prop for more control (e.g., `target="_blank"`, `target="_parent"`). The `target` prop takes precedence over `external` if both are set.

### Mixing Menu Items with Raw Elements

In most cases, use `pds-dropdown-menu-item` for menu options. However, for edge cases requiring native browser or framework behavior (such as handling non-GET HTTP methods or framework-specific data attributes), you can mix in raw `<a>` or `<button>` elements. These elements are included in keyboard navigation but require additional CSS in your application for hover and focus states (see example below).

The example below demonstrates mixing a standard menu item with raw `<a>` and `<button>` elements that use framework-specific data attributes.

<DocCanvas
mdxSource={{
react:`
{/* Hover/focus styles for raw elements (add to your app's CSS) */}
<style>{\`
pds-dropdown-menu > a:hover,
pds-dropdown-menu > button:hover {
background-color: var(--pine-color-background-muted);
}
pds-dropdown-menu > a:focus,
pds-dropdown-menu > button:focus {
outline: var(--pine-outline-focus);
outline-offset: var(--pine-border-width);
}
pds-dropdown-menu > a.destructive:hover {
background-color: var(--pine-color-danger-disabled);
}
pds-dropdown-menu > a.destructive:focus {
outline: var(--pine-outline-focus-danger);
}
\`}</style>

<PdsDropdownMenu>
<PdsButton slot="trigger">Actions</PdsButton>
<PdsDropdownMenuItem href="/view">View</PdsDropdownMenuItem>
<PdsDropdownMenuSeparator />
<a href="/edit" data-remote="true">Edit</a>
<a href="/archive" data-method="post">Archive</a>
<PdsDropdownMenuSeparator />
<button type="button" data-action="modal#open">Open Modal</button>
<a href="/delete" data-method="delete" className="destructive">Delete</a>
</PdsDropdownMenu>
`,
webComponent:`
<!-- Hover/focus styles for raw elements (add to your app's CSS) -->
<style>
pds-dropdown-menu > a:hover,
pds-dropdown-menu > button:hover {
background-color: var(--pine-color-background-muted);
}
pds-dropdown-menu > a:focus,
pds-dropdown-menu > button:focus {
outline: var(--pine-outline-focus);
outline-offset: var(--pine-border-width);
}
pds-dropdown-menu > a.destructive:hover {
background-color: var(--pine-color-danger-disabled);
}
pds-dropdown-menu > a.destructive:focus {
outline: var(--pine-outline-focus-danger);
}
</style>

<pds-dropdown-menu>
<pds-button slot="trigger">Actions</pds-button>
<pds-dropdown-menu-item href="/view">View</pds-dropdown-menu-item>
<pds-dropdown-menu-separator />
<a href="/edit" data-remote="true">Edit</a>
<a href="/archive" data-method="post">Archive</a>
<pds-dropdown-menu-separator />
<button type="button" data-action="modal#open">Open Modal</button>
<a href="/delete" data-method="delete" class="destructive">Delete</a>
</pds-dropdown-menu>
`
}}
>
<style>
{`
.raw-elements-example pds-dropdown-menu > a:hover,
.raw-elements-example pds-dropdown-menu > button:hover {
background-color: var(--pine-color-background-muted);
}
.raw-elements-example pds-dropdown-menu > a:focus,
.raw-elements-example pds-dropdown-menu > button:focus {
outline: var(--pine-outline-focus);
outline-offset: var(--pine-border-width);
}
.raw-elements-example pds-dropdown-menu > a.destructive:hover {
background-color: var(--pine-color-danger-disabled);
}
.raw-elements-example pds-dropdown-menu > a.destructive:focus {
outline: var(--pine-outline-focus-danger);
}
`}
</style>
<div className="raw-elements-example" style={{ height: '275px', width: '100%', textAlign: 'center' }}>
<pds-dropdown-menu>
<pds-button slot="trigger">Actions</pds-button>
<pds-dropdown-menu-item href="/view">View</pds-dropdown-menu-item>
<pds-dropdown-menu-separator></pds-dropdown-menu-separator>
<a href="/edit" data-remote="true">Edit</a>
<a href="/archive" data-method="post">Archive</a>
<pds-dropdown-menu-separator></pds-dropdown-menu-separator>
<button type="button" data-action="modal#open">Open Modal</button>
<a href="/delete" data-method="delete" className="destructive">Delete</a>
</pds-dropdown-menu>
</div>
</DocCanvas>

**Raw element attributes:**
- Add `class="destructive"` for danger/destructive text color
- Add `aria-disabled="true"` to visually disable an anchor element
- Add `disabled` attribute to visually disable a button element

>**Note:** Use `pds-dropdown-menu-item` for most cases. Raw elements should only be used when you need native browser behavior that cannot work through the component's Shadow DOM (e.g., framework-specific data attributes for form submissions). Due to Shadow DOM limitations, hover and focus states for raw elements must be styled by the consuming application's CSS.

### With Disabled Items

Menu items can be disabled using the disabled attribute.
Expand Down
41 changes: 41 additions & 0 deletions libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,44 @@
border: var(--pine-border);
}
}

// Styles for raw <a> and <button> elements slotted into the menu
// These are allowed for edge cases requiring native browser/framework behavior
// (e.g., Rails UJS, Turbo) that cannot work through Shadow DOM.
//
// Note: ::slotted() cannot be combined with pseudo-classes like :hover or :focus.
// Raw elements will receive base styling here; hover/focus states must be handled
// by the consuming application's CSS if needed.
::slotted(a),
::slotted(button) {
align-items: center;
appearance: none;
background: transparent;
border: 0;
border-radius: var(--pine-dimension-xs);
box-sizing: border-box;
color: var(--pine-color-text) !important;
cursor: pointer;
display: flex;
flex-grow: 1;
font: var(--pine-typography-body-medium);
gap: var(--pine-dimension-xs);
margin: calc(var(--pine-border-width) + 2px);
padding: var(--pine-dimension-xs);
text-align: start;
text-decoration: none !important;
width: calc(100% - calc(var(--pine-border-width) + 2px) * 2);
}

// Destructive variant for raw elements
::slotted(.destructive) {
color: var(--pine-color-danger) !important;
}

// Disabled state for raw elements (using attribute selectors, not pseudo-classes)
::slotted([aria-disabled="true"]),
::slotted([disabled]) {
cursor: not-allowed;
opacity: 0.5;
pointer-events: none;
}
109 changes: 80 additions & 29 deletions libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { computePosition,
autoUpdate,
} from '@floating-ui/dom';

/**
* Union type for focusable menu items (component or raw elements)
*/
type MenuItemElement = HTMLPdsDropdownMenuItemElement | HTMLAnchorElement | HTMLButtonElement;

/**
* @part menu-panel - Exposes the dropdown menu container for styling.
*/
Expand All @@ -21,7 +26,7 @@ export class PdsDropdownMenu implements BasePdsProps {
private triggerEl: HTMLElement;
private panelEl: HTMLPdsBoxElement;
private isOpen: boolean = false;
private menuItems: HTMLPdsDropdownMenuItemElement[] = [];
private menuItems: MenuItemElement[] = [];
private cleanupAutoUpdate: (() => void) | null = null;

@Element() host: HTMLPdsDropdownMenuElement;
Expand Down Expand Up @@ -70,17 +75,28 @@ export class PdsDropdownMenu implements BasePdsProps {
// Get all elements assigned to this slot
const assignedElements = this.slotEl.assignedElements();

// ensure assignedElements only contains pds-dropdown-menu-item or pds-dropdown-menu-separator
// if there are other elements, throw an error
const invalidElements = assignedElements.filter(el => el.tagName.toLowerCase() !== 'pds-dropdown-menu-item' && el.tagName.toLowerCase() !== 'pds-dropdown-menu-separator');
// Allowed elements: pds-dropdown-menu-item, pds-dropdown-menu-separator, <a>, <button>
// Raw <a> and <button> elements are allowed for edge cases requiring native browser/framework
// behavior (e.g., Rails UJS, Turbo) that cannot work through Shadow DOM.
const allowedTags = ['pds-dropdown-menu-item', 'pds-dropdown-menu-separator', 'a', 'button'];
const invalidElements = assignedElements.filter(
el => !allowedTags.includes(el.tagName.toLowerCase())
);

if (invalidElements.length > 0) {
throw new Error(`pds-dropdown-menu only accepts pds-dropdown-menu-item and pds-dropdown-menu-separator elements`);
const invalidTags = invalidElements.map(el => el.tagName.toLowerCase()).join(', ');
console.warn(
`pds-dropdown-menu: Unexpected element(s) found: ${invalidTags}. ` +
`Expected: ${allowedTags.join(', ')}`
);
}

// Store all menu items for keyboard navigation
this.menuItems = assignedElements.filter(
el => el.tagName.toLowerCase() === 'pds-dropdown-menu-item'
) as HTMLPdsDropdownMenuItemElement[];
// Store all focusable items for keyboard navigation
// This includes pds-dropdown-menu-item components and raw <a>/<button> elements
this.menuItems = assignedElements.filter(el => {
const tag = el.tagName.toLowerCase();
return tag === 'pds-dropdown-menu-item' || tag === 'a' || tag === 'button';
}) as MenuItemElement[];
}

// Toggle dropdown open/closed
Expand Down Expand Up @@ -152,30 +168,65 @@ export class PdsDropdownMenu implements BasePdsProps {
this.toggleDropdown();
}

// Check if a menu item is disabled (handles both component and raw elements)
private isItemDisabled(item: MenuItemElement): boolean {
const tagName = item.tagName.toLowerCase();

if (tagName === 'pds-dropdown-menu-item') {
return (item as HTMLPdsDropdownMenuItemElement).disabled;
} else if (tagName === 'button') {
return (item as HTMLButtonElement).disabled;
} else if (tagName === 'a') {
return item.getAttribute('aria-disabled') === 'true';
}
return false;
}

// Get the index of the currently focused menu item
private getFocusedItemIndex(): number {
const activeElement = document.activeElement as HTMLPdsDropdownMenuItemElement | null;
const activeElement = document.activeElement as MenuItemElement | null;
if (!activeElement) return -1;
return this.menuItems.findIndex(item => item === activeElement);

// For raw elements, check direct match
// For pds-dropdown-menu-item, also check if the active element is inside the shadow root
return this.menuItems.findIndex(item => {
if (item === activeElement) return true;

// Check if activeElement is inside the item's shadow root (for pds-dropdown-menu-item)
if (item.tagName.toLowerCase() === 'pds-dropdown-menu-item') {
const shadowRoot = (item as HTMLPdsDropdownMenuItemElement).shadowRoot;
if (shadowRoot?.contains(activeElement)) return true;
}

return false;
});
}

// Focus a specific menu item by index
private focusItemByIndex(index: number): void {
if (index >= 0 && index < this.menuItems.length) {
this.currentFocusIndex = index;

// Focus the inner button/link instead of the host element
const menuItem = this.menuItems[index];
const innerButton = menuItem.shadowRoot?.querySelector('button');
const innerLink = menuItem.shadowRoot?.querySelector('pds-link')?.shadowRoot?.querySelector('a');

if (innerButton) {
return innerButton.focus();
} else if (innerLink) {
return innerLink.focus();
const item = this.menuItems[index];
const tagName = item.tagName.toLowerCase();

if (tagName === 'pds-dropdown-menu-item') {
// For pds-dropdown-menu-item, focus the inner element
const menuItem = item as HTMLPdsDropdownMenuItemElement;
const innerButton = menuItem.shadowRoot?.querySelector('button');
const innerLink = menuItem.shadowRoot?.querySelector('pds-link')?.shadowRoot?.querySelector('a')
|| menuItem.shadowRoot?.querySelector('a');

if (innerButton) {
innerButton.focus();
} else if (innerLink) {
innerLink.focus();
} else {
// Fallback to focusing the host
menuItem.focus();
}
} else {
// Fallback to focusing the host if we can't find the inner element
menuItem.focus();
// For raw <a> or <button> elements, focus directly
(item as HTMLElement).focus();
}
}
}
Expand All @@ -188,7 +239,7 @@ export class PdsDropdownMenu implements BasePdsProps {
let attempts = 0;
const maxAttempts = this.menuItems.length;

while (attempts < maxAttempts && this.menuItems[nextIndex].disabled) {
while (attempts < maxAttempts && this.isItemDisabled(this.menuItems[nextIndex])) {
nextIndex = (nextIndex + 1) % this.menuItems.length;
attempts++;
}
Expand All @@ -209,7 +260,7 @@ export class PdsDropdownMenu implements BasePdsProps {
let attempts = 0;
const maxAttempts = this.menuItems.length;

while (attempts < maxAttempts && this.menuItems[prevIndex].disabled) {
while (attempts < maxAttempts && this.isItemDisabled(this.menuItems[prevIndex])) {
prevIndex = prevIndex <= 0 ? this.menuItems.length - 1 : prevIndex - 1;
attempts++;
}
Expand Down Expand Up @@ -246,7 +297,7 @@ export class PdsDropdownMenu implements BasePdsProps {
if (this.menuItems.length > 0) {
// Find first non-disabled item
let firstIndex = 0;
while (firstIndex < this.menuItems.length && this.menuItems[firstIndex].disabled) {
while (firstIndex < this.menuItems.length && this.isItemDisabled(this.menuItems[firstIndex])) {
firstIndex++;
}
if (firstIndex < this.menuItems.length) {
Expand All @@ -260,7 +311,7 @@ export class PdsDropdownMenu implements BasePdsProps {
if (this.menuItems.length > 0) {
// Find last non-disabled item
let lastIndex = this.menuItems.length - 1;
while (lastIndex >= 0 && this.menuItems[lastIndex].disabled) {
while (lastIndex >= 0 && this.isItemDisabled(this.menuItems[lastIndex])) {
lastIndex--;
}
if (lastIndex >= 0) {
Expand Down Expand Up @@ -293,7 +344,7 @@ export class PdsDropdownMenu implements BasePdsProps {

// Find the first non-disabled item
let firstFocusableIndex = 0;
while (firstFocusableIndex < this.menuItems.length && this.menuItems[firstFocusableIndex].disabled) {
while (firstFocusableIndex < this.menuItems.length && this.isItemDisabled(this.menuItems[firstFocusableIndex])) {
firstFocusableIndex++;
}

Expand All @@ -306,7 +357,7 @@ export class PdsDropdownMenu implements BasePdsProps {

// Find the first non-disabled item
let firstFocusableIndex = 0;
while (firstFocusableIndex < this.menuItems.length && this.menuItems[firstFocusableIndex].disabled) {
while (firstFocusableIndex < this.menuItems.length && this.isItemDisabled(this.menuItems[firstFocusableIndex])) {
firstFocusableIndex++;
}

Expand Down
Loading
Loading