diff --git a/libs/core/src/components/pds-dropdown-menu/docs/pds-dropdown-menu.mdx b/libs/core/src/components/pds-dropdown-menu/docs/pds-dropdown-menu.mdx index 5dce2de1b..60981793f 100644 --- a/libs/core/src/components/pds-dropdown-menu/docs/pds-dropdown-menu.mdx +++ b/libs/core/src/components/pds-dropdown-menu/docs/pds-dropdown-menu.mdx @@ -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 `` or ` + Delete + +`, + webComponent:` + + + + + Actions + View + + Edit + Archive + + + Delete + +` + }} +> + +
+ + Actions + View + + Edit + Archive + + + Delete + +
+ + +**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. diff --git a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu.scss b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu.scss index 5ddc2be7f..4322d4b2d 100644 --- a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu.scss +++ b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu.scss @@ -19,3 +19,44 @@ border: var(--pine-border); } } + +// Styles for raw and + Item 1 + Delete + + `, + }); + + const component = page.rootInstance; + const contentSlot = page.root.shadowRoot?.querySelector('pds-box > slot'); + + // Create a mock event with menu item and raw anchor + const mockEvent = { + target: contentSlot, + assignedElements: () => [ + page.body.querySelector('pds-dropdown-menu-item'), + page.body.querySelector('a') + ] + }; + + // Spy on console.warn to ensure no warning is logged + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // Call should not warn + component.handleSlotChange(mockEvent as any); + + // No warning should be logged for allowed elements + expect(warnSpy).not.toHaveBeenCalled(); + + // Both elements should be in menuItems for keyboard navigation + expect(component.menuItems.length).toBe(2); + + warnSpy.mockRestore(); + }); + + it('allows raw + Item 1 + + + `, + }); + + const component = page.rootInstance; + const contentSlot = page.root.shadowRoot?.querySelector('pds-box > slot'); + + // Get the raw button (not the trigger) + const rawButton = page.body.querySelectorAll('button')[1]; + + // Create a mock event with menu item and raw button + const mockEvent = { + target: contentSlot, + assignedElements: () => [ + page.body.querySelector('pds-dropdown-menu-item'), + rawButton + ] + }; + + // Spy on console.warn to ensure no warning is logged + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // Call should not warn + component.handleSlotChange(mockEvent as any); + + // No warning should be logged for allowed elements + expect(warnSpy).not.toHaveBeenCalled(); + + // Both elements should be in menuItems for keyboard navigation + expect(component.menuItems.length).toBe(2); + + warnSpy.mockRestore(); + }); + + it('skips disabled items during keyboard navigation', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenu, PdsDropdownMenuItem, PdsBox], + html: ` + + + Item 1 + Item 2 (Disabled) + Item 3 + + `, + }); + + const component = page.rootInstance; + component.isOpen = true; + + // Setup slot change to populate menuItems + const contentSlot = page.root?.shadowRoot?.querySelector('pds-box > slot'); + const mockEvent = { + target: contentSlot, + assignedElements: () => Array.from(page.body.querySelectorAll('pds-dropdown-menu-item')) + }; + component.handleSlotChange(mockEvent as any); + + // Verify we have 3 items + expect(component.menuItems.length).toBe(3); + + // Start at first item + component.currentFocusIndex = 0; + + // Call focusNextItem - should skip disabled item 2 and go to item 3 + component.focusNextItem(); + expect(component.currentFocusIndex).toBe(2); // Should skip index 1 (disabled) }); it('applies specified placement', async () => { @@ -368,11 +494,11 @@ describe('pds-dropdown-menu', () => { const mockTrigger = document.createElement('button'); component.triggerEl = mockTrigger; - // Create menu items for testing with first item available + // Create menu items for testing with proper tagName property component.menuItems = [ - { disabled: false, focus: jest.fn() } as any, - { disabled: false, focus: jest.fn() } as any, - { disabled: true, focus: jest.fn() } as any + { tagName: 'PDS-DROPDOWN-MENU-ITEM', disabled: false, focus: jest.fn() } as any, + { tagName: 'PDS-DROPDOWN-MENU-ITEM', disabled: false, focus: jest.fn() } as any, + { tagName: 'PDS-DROPDOWN-MENU-ITEM', disabled: true, focus: jest.fn() } as any ]; // Mock document.activeElement to be the trigger diff --git a/libs/core/src/components/pds-table/pds-table.tsx b/libs/core/src/components/pds-table/pds-table.tsx index 79d368aee..8b117bd92 100644 --- a/libs/core/src/components/pds-table/pds-table.tsx +++ b/libs/core/src/components/pds-table/pds-table.tsx @@ -86,9 +86,12 @@ export class PdsTable { } // Apply default sort if specified + // Use requestAnimationFrame to defer until child components are fully initialized if (this.defaultSortColumn) { - void this.applyDefaultSort().catch((err) => { - console.warn('Failed to apply default sort.', err); + requestAnimationFrame(() => { + void this.applyDefaultSort().catch((err) => { + console.warn('Failed to apply default sort.', err); + }); }); } } diff --git a/libs/core/src/components/pds-table/test/pds-table.spec.tsx b/libs/core/src/components/pds-table/test/pds-table.spec.tsx index d6a3e5818..af39f72d3 100644 --- a/libs/core/src/components/pds-table/test/pds-table.spec.tsx +++ b/libs/core/src/components/pds-table/test/pds-table.spec.tsx @@ -6,6 +6,9 @@ import { PdsTableBody } from '../pds-table-body/pds-table-body'; import { PdsTableRow } from '../pds-table-row/pds-table-row'; import { PdsTableCell } from '../pds-table-cell/pds-table-cell'; +// Helper to wait for requestAnimationFrame to complete +const flushAnimationFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + describe('pds-table', () => { it('renders', async () => { const page = await newSpecPage({ @@ -399,6 +402,7 @@ describe('pds-table', () => { `, }); + await flushAnimationFrame(); await page.waitForChanges(); const tableBody = page.body.querySelector('pds-table-body') as HTMLElement; @@ -436,6 +440,7 @@ describe('pds-table', () => { `, }); + await flushAnimationFrame(); await page.waitForChanges(); const tableBody = page.body.querySelector('pds-table-body') as HTMLElement; @@ -465,6 +470,7 @@ describe('pds-table', () => { `, }); + await flushAnimationFrame(); await page.waitForChanges(); const headCells = page.body.querySelectorAll('pds-table-head-cell'); @@ -496,6 +502,7 @@ describe('pds-table', () => { `, }); + await flushAnimationFrame(); await page.waitForChanges(); expect(consoleWarnSpy).toHaveBeenCalledWith('Default sort column "NonExistent" not found.'); @@ -547,6 +554,7 @@ describe('pds-table', () => { `, }); + await flushAnimationFrame(); await page.waitForChanges(); // Should not throw and header should still be marked active