Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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