diff --git a/libs/core/src/components.d.ts b/libs/core/src/components.d.ts index 5ad9b5bb5..ce2bec676 100644 --- a/libs/core/src/components.d.ts +++ b/libs/core/src/components.d.ts @@ -1081,11 +1081,26 @@ export namespace Components { * If provided, renders the dropdown-item as an anchor (``) element instead of a button. */ "href": string | undefined; + /** + * HTTP method to use for link navigation. For non-GET methods (post, put, patch, delete), the component will handle form submission internally. Also adds data-method and data-turbo-method attributes to the internal anchor for framework integration. Only applies when href is provided. + * @defaultValue undefined (link navigates normally) + */ + "httpMethod"?: 'get' | 'post' | 'put' | 'patch' | 'delete'; /** * Specifies where to open the linked document when href is provided. Takes precedence over the `external` prop if both are set. Only applies when href is set. * @defaultValue undefined */ "target"?: '_blank' | '_self' | '_parent' | '_top'; + /** + * Sets data-turbo attribute on the internal anchor. Useful for enabling or disabling framework-specific navigation handling. Only applies when href is provided. + * @defaultValue undefined (no data-turbo attribute) + */ + "turbo"?: boolean; + /** + * Sets data-turbo-frame attribute on the internal anchor. Useful for framework integration with frame-based navigation. Only applies when href is provided. + * @defaultValue undefined (no data-turbo-frame attribute) + */ + "turboFrame"?: string; } interface PdsDropdownMenuSeparator { /** @@ -2502,6 +2517,7 @@ declare global { }; interface HTMLPdsDropdownMenuItemElementEventMap { "pdsClick": {itemIndex: number, item: HTMLPdsDropdownMenuItemElement, content: string}; + "pdsBeforeSubmit": { href: string; method: string }; } interface HTMLPdsDropdownMenuItemElement extends Components.PdsDropdownMenuItem, HTMLStencilElement { addEventListener(type: K, listener: (this: HTMLPdsDropdownMenuItemElement, ev: PdsDropdownMenuItemCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; @@ -4064,6 +4080,15 @@ declare namespace LocalJSX { * If provided, renders the dropdown-item as an anchor (``) element instead of a button. */ "href"?: string | undefined; + /** + * HTTP method to use for link navigation. For non-GET methods (post, put, patch, delete), the component will handle form submission internally. Also adds data-method and data-turbo-method attributes to the internal anchor for framework integration. Only applies when href is provided. + * @defaultValue undefined (link navigates normally) + */ + "httpMethod"?: 'get' | 'post' | 'put' | 'patch' | 'delete'; + /** + * Emitted before form submission for non-GET http methods. Call event.preventDefault() to cancel the submission and handle it yourself. Useful for custom confirmation dialogs or app-specific handling. + */ + "onPdsBeforeSubmit"?: (event: PdsDropdownMenuItemCustomEvent<{ href: string; method: string }>) => void; /** * Emitted when the dropdown-item is clicked. */ @@ -4073,6 +4098,16 @@ declare namespace LocalJSX { * @defaultValue undefined */ "target"?: '_blank' | '_self' | '_parent' | '_top'; + /** + * Sets data-turbo attribute on the internal anchor. Useful for enabling or disabling framework-specific navigation handling. Only applies when href is provided. + * @defaultValue undefined (no data-turbo attribute) + */ + "turbo"?: boolean; + /** + * Sets data-turbo-frame attribute on the internal anchor. Useful for framework integration with frame-based navigation. Only applies when href is provided. + * @defaultValue undefined (no data-turbo-frame attribute) + */ + "turboFrame"?: string; } interface PdsDropdownMenuSeparator { /** 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..c620a3304 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,120 @@ 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. +### With HTTP Methods + +Menu items support non-GET HTTP methods (`post`, `put`, `patch`, `delete`). When `http-method` is specified, the component handles form submission internally, which is useful for actions that modify data on the server. + + + Actions + View + + Duplicate + + + + Delete + + +`, + webComponent:` + + Actions + View + + Duplicate + + + + Delete + + +` + }} +> +
+ + Actions + View + Duplicate + + Delete + +
+
+ +>**How it works:** For non-GET methods, clicking the menu item creates a hidden form with the proper `action`, adds the CSRF token (if available via `meta[name="csrf-token"]`), adds a `_method` field for method override, and submits the form. The internal anchor also receives `data-method` and `data-turbo-method` attributes. + +### With Data Attributes + +Menu items can add common data attributes to the internal anchor element using the `turbo-frame` and `turbo` props. These are useful for integration with JavaScript frameworks that rely on data attributes for navigation behavior. + + + Navigation + + Target Frame + + + Full Page Navigation + + + Disable Framework Handling + + +`, + webComponent:` + + Navigation + + Target Frame + + + Full Page Navigation + + + Disable Framework Handling + + +` + }} +> +
+ + Navigation + Target Frame + Full Page Navigation + Disable Framework Handling + +
+
+ +### Intercepting Form Submission + +The `pdsBeforeSubmit` event is emitted before form submission for non-GET methods. You can cancel this event to handle the submission yourself (e.g., to show a confirmation dialog). + +```javascript +// Example: Intercept delete to show confirmation +menuItem.addEventListener('pdsBeforeSubmit', (event) => { + event.preventDefault(); // Cancel automatic form submission + + if (confirm('Are you sure you want to delete this item?')) { + // Manually submit if confirmed + const form = document.createElement('form'); + form.method = 'POST'; + form.action = event.detail.href; + // Add CSRF token and _method field... + document.body.appendChild(form); + form.submit(); + } +}); +``` + ### 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-item/pds-dropdown-menu-item.scss b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/pds-dropdown-menu-item.scss index 36d886515..9b7e07095 100644 --- a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/pds-dropdown-menu-item.scss +++ b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/pds-dropdown-menu-item.scss @@ -29,6 +29,7 @@ margin: calc(var(--pine-border-width) + 2px); padding: var(--pine-dimension-xs); text-align: start; /* Ensure text aligns properly */ + text-decoration: none; /* Prevent underline on anchor elements */ width: 100%; /* Ensure full width */ &:hover { @@ -44,6 +45,10 @@ outline-offset: var(--pine-border-width); } + /* External icon spacing */ + pds-icon { + margin-inline-start: var(--pine-dimension-2xs); + } } :host(.destructive) { @@ -66,17 +71,3 @@ } } -/* Remove outline on contained links using the custom property */ -pds-link::part(link):focus, -pds-link::part(link):focus-visible { - box-shadow: none; - outline: none; -} - -pds-link::part(link) { - display: block; - margin: calc(var(--pine-dimension-xs) * -1); - padding: var(--pine-dimension-xs); - text-decoration: none; - width: calc(100% + var(--pine-dimension-xs) * 2); -} diff --git a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/pds-dropdown-menu-item.tsx b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/pds-dropdown-menu-item.tsx index 2588f33fb..3e851b9ac 100644 --- a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/pds-dropdown-menu-item.tsx +++ b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/pds-dropdown-menu-item.tsx @@ -1,5 +1,6 @@ import { Component, Element, Event, EventEmitter, h, Host, Method, Prop, State } from '@stencil/core'; import type { BasePdsProps } from '@utils/interfaces'; +import { launch } from '@pine-ds/icons/icons'; @Component({ tag: 'pds-dropdown-menu-item', @@ -47,12 +48,45 @@ export class PdsDropdownMenuItem implements BasePdsProps { */ @Prop({ reflect: true }) target?: '_blank' | '_self' | '_parent' | '_top'; + /** + * HTTP method to use for link navigation. + * For non-GET methods (post, put, patch, delete), the component will handle + * form submission internally. Also adds data-method and data-turbo-method + * attributes to the internal anchor for framework integration. + * Only applies when href is provided. + * @defaultValue undefined (link navigates normally) + */ + @Prop() httpMethod?: 'get' | 'post' | 'put' | 'patch' | 'delete'; + + /** + * Sets data-turbo-frame attribute on the internal anchor. + * Useful for framework integration with frame-based navigation. + * Only applies when href is provided. + * @defaultValue undefined (no data-turbo-frame attribute) + */ + @Prop() turboFrame?: string; + + /** + * Sets data-turbo attribute on the internal anchor. + * Useful for enabling or disabling framework-specific navigation handling. + * Only applies when href is provided. + * @defaultValue undefined (no data-turbo attribute) + */ + @Prop() turbo?: boolean; + /** * Emitted when the dropdown-item is clicked. * */ @Event() pdsClick: EventEmitter<{itemIndex: number, item: HTMLPdsDropdownMenuItemElement, content: string}>; + /** + * Emitted before form submission for non-GET http methods. + * Call event.preventDefault() to cancel the submission and handle it yourself. + * Useful for custom confirmation dialogs or app-specific handling. + */ + @Event({ cancelable: true }) pdsBeforeSubmit: EventEmitter<{ href: string; method: string }>; + /** * Trigger the click event */ @@ -89,25 +123,114 @@ export class PdsDropdownMenuItem implements BasePdsProps { this.hasFocus = false; } + /** + * Submits a request as a form, handling non-GET HTTP methods. + * Creates a hidden form with CSRF token and _method field for proper + * server-side handling of DELETE, PUT, and PATCH requests. + */ + private submitAsForm() { + if (!this.href) return; + + // Create a form element + const form = document.createElement('form'); + form.method = 'POST'; + form.action = this.href; + form.style.display = 'none'; + + // Add CSRF token if available + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + if (csrfToken) { + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = 'authenticity_token'; + csrfInput.value = csrfToken; + form.appendChild(csrfInput); + } + + // Add _method input for non-POST methods (DELETE, PUT, PATCH) + if (this.httpMethod && this.httpMethod.toLowerCase() !== 'post') { + const methodInput = document.createElement('input'); + methodInput.type = 'hidden'; + methodInput.name = '_method'; + methodInput.value = this.httpMethod.toLowerCase(); + form.appendChild(methodInput); + } + + // Append form to body and submit + document.body.appendChild(form); + form.submit(); + } + + /** + * Handles click on the internal anchor element. + * Emits pdsClick, then handles form submission for non-GET methods. + */ + private handleLinkClick = (event: MouseEvent) => { + // IMPORTANT: Always call handleClick to emit pdsClick event + // This ensures Stimulus patterns (data-action="pdsClick->...") keep working + this.handleClick(); + + // Only handle form submission for non-GET methods when httpMethod prop is explicitly set + if (this.httpMethod && this.httpMethod.toLowerCase() !== 'get') { + // Emit cancellable pdsBeforeSubmit event + const beforeSubmitEvent = this.pdsBeforeSubmit.emit({ + href: this.href, + method: this.httpMethod, + }); + + // Check if the event was cancelled (for custom confirmation dialogs, etc.) + // Stencil's emit() returns the CustomEvent, we check defaultPrevented + if (!beforeSubmitEvent.defaultPrevented) { + // No one cancelled - proceed with form submission + event.preventDefault(); + this.submitAsForm(); + } else { + // Event was cancelled - app handles it (e.g., showing confirmation dialog) + event.preventDefault(); + } + } + // Otherwise, let the link navigate normally (existing behavior) + } + private renderElement() { if (this.href !== undefined) { + const targetValue = this.target || (this.external ? '_blank' : undefined); + const relValue = targetValue === '_blank' ? 'noopener noreferrer' : undefined; + + // Build link attributes + const linkAttrs: { [key: string]: string | number | boolean | undefined | null | ((e: MouseEvent) => void) | ((e: KeyboardEvent) => void) | (() => void) | { [key: string]: boolean } } = { + href: this.disabled ? undefined : this.href, + target: targetValue, + rel: relValue, + class: { + 'pds-dropdown-menu-item__content': true, + 'has-focus': this.hasFocus + }, + tabIndex: this.disabled ? -1 : 0, + onClick: this.handleLinkClick, + onKeyDown: this.handleKeyDown, + onFocus: this.handleFocus, + onBlur: this.handleBlur, + 'aria-disabled': this.disabled ? 'true' : null, + }; + + // Add Rails/Turbo compatibility attributes ONLY when props are provided + if (this.httpMethod) { + linkAttrs['data-method'] = this.httpMethod; + linkAttrs['data-turbo-method'] = this.httpMethod; + } + if (this.turboFrame) { + linkAttrs['data-turbo-frame'] = this.turboFrame; + } + if (this.turbo !== undefined) { + linkAttrs['data-turbo'] = this.turbo.toString(); + } + return ( - +
- + {this.external && } + ); } diff --git a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/readme.md b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/readme.md index fc2a8422f..8854222a1 100644 --- a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/readme.md +++ b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/readme.md @@ -7,21 +7,25 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ----------- | -| `componentId` | `component-id` | A unique identifier used for the underlying component `id` attribute. | `string` | `undefined` | -| `destructive` | `destructive` | It determines whether or not the dropdown-item is destructive. | `boolean` | `false` | -| `disabled` | `disabled` | It determines whether or not the dropdown-item is disabled. | `boolean` | `false` | -| `external` | `external` | Determines whether the link should open in a new tab and display an external icon. This is a simpler alternative to using `target="_blank"` for the common case. | `boolean` | `false` | -| `href` | `href` | If provided, renders the dropdown-item as an anchor (``) element instead of a button. | `string` | `undefined` | -| `target` | `target` | Specifies where to open the linked document when href is provided. Takes precedence over the `external` prop if both are set. Only applies when href is set. | `"_blank" \| "_parent" \| "_self" \| "_top"` | `undefined` | +| Property | Attribute | Description | Type | Default | +| ------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | ----------- | +| `componentId` | `component-id` | A unique identifier used for the underlying component `id` attribute. | `string` | `undefined` | +| `destructive` | `destructive` | It determines whether or not the dropdown-item is destructive. | `boolean` | `false` | +| `disabled` | `disabled` | It determines whether or not the dropdown-item is disabled. | `boolean` | `false` | +| `external` | `external` | Determines whether the link should open in a new tab and display an external icon. This is a simpler alternative to using `target="_blank"` for the common case. | `boolean` | `false` | +| `href` | `href` | If provided, renders the dropdown-item as an anchor (``) element instead of a button. | `string` | `undefined` | +| `httpMethod` | `http-method` | HTTP method to use for link navigation. For non-GET methods (post, put, patch, delete), the component will handle form submission internally. Also adds data-method and data-turbo-method attributes to the internal anchor for framework integration. Only applies when href is provided. | `"delete" \| "get" \| "patch" \| "post" \| "put"` | `undefined` | +| `target` | `target` | Specifies where to open the linked document when href is provided. Takes precedence over the `external` prop if both are set. Only applies when href is set. | `"_blank" \| "_parent" \| "_self" \| "_top"` | `undefined` | +| `turbo` | `turbo` | Sets data-turbo attribute on the internal anchor. Useful for enabling or disabling framework-specific navigation handling. Only applies when href is provided. | `boolean` | `undefined` | +| `turboFrame` | `turbo-frame` | Sets data-turbo-frame attribute on the internal anchor. Useful for framework integration with frame-based navigation. Only applies when href is provided. | `string` | `undefined` | ## Events -| Event | Description | Type | -| ---------- | ------------------------------------------ | -------------------------------------------------------------------------------------------- | -| `pdsClick` | Emitted when the dropdown-item is clicked. | `CustomEvent<{ itemIndex: number; item: HTMLPdsDropdownMenuItemElement; content: string; }>` | +| Event | Description | Type | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | +| `pdsBeforeSubmit` | Emitted before form submission for non-GET http methods. Call event.preventDefault() to cancel the submission and handle it yourself. Useful for custom confirmation dialogs or app-specific handling. | `CustomEvent<{ href: string; method: string; }>` | +| `pdsClick` | Emitted when the dropdown-item is clicked. | `CustomEvent<{ itemIndex: number; item: HTMLPdsDropdownMenuItemElement; content: string; }>` | ## Methods @@ -41,13 +45,12 @@ Type: `Promise` ### Depends on -- [pds-link](../../pds-link) +- pds-icon ### Graph ```mermaid graph TD; - pds-dropdown-menu-item --> pds-link - pds-link --> pds-icon + pds-dropdown-menu-item --> pds-icon style pds-dropdown-menu-item fill:#f9f,stroke:#333,stroke-width:4px ``` diff --git a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/stories/pds-dropdown-menu-item.stories.js b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/stories/pds-dropdown-menu-item.stories.js new file mode 100644 index 000000000..d9d267cda --- /dev/null +++ b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/stories/pds-dropdown-menu-item.stories.js @@ -0,0 +1,34 @@ +import { html } from 'lit'; + +export default { + component: 'pds-dropdown-menu-item', + title: 'components/Dropdown Menu/Dropdown Menu Item', + parameters: { + docs: { + description: { + component: 'Individual menu items for use within pds-dropdown-menu. Supports links, buttons, destructive actions, and HTTP method handling for non-GET requests.', + }, + }, + }, +}; + +const Template = (args) => html` +
+ + Open Menu + + ${args.label} + + Another Item + +
`; + +export const Default = Template.bind({}); +Default.args = { + componentId: 'menu-item-default', + label: 'Menu Item', + href: undefined, +}; diff --git a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/test/pds-dropdown-menu-item.spec.tsx b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/test/pds-dropdown-menu-item.spec.tsx index dbbd50f8c..f4b0e7662 100644 --- a/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/test/pds-dropdown-menu-item.spec.tsx +++ b/libs/core/src/components/pds-dropdown-menu/pds-dropdown-menu-item/test/pds-dropdown-menu-item.spec.tsx @@ -1,6 +1,5 @@ import { newSpecPage } from '@stencil/core/testing'; import { PdsDropdownMenuItem } from '../pds-dropdown-menu-item'; -import { PdsLink } from '../../../pds-link/pds-link'; describe('pds-dropdown-menu-item', () => { it('renders as a button by default', async () => { @@ -28,7 +27,7 @@ describe('pds-dropdown-menu-item', () => { it('renders as a link when href is provided', async () => { const page = await newSpecPage({ - components: [PdsDropdownMenuItem, PdsLink], + components: [PdsDropdownMenuItem], html: `Link Item`, }); @@ -37,15 +36,14 @@ describe('pds-dropdown-menu-item', () => { } const shadowRoot = page.root.shadowRoot; - const linkElement = shadowRoot.querySelector('pds-link'); + const linkElement = shadowRoot.querySelector('a'); expect(linkElement).not.toBeNull(); - // The href is set as a property, not an attribute in the spec test - expect(linkElement?.href).toBe('https://example.com'); + expect(linkElement?.getAttribute('href')).toBe('https://example.com'); }); it('renders as a link with external prop when both href and external are provided', async () => { const page = await newSpecPage({ - components: [PdsDropdownMenuItem, PdsLink], + components: [PdsDropdownMenuItem], html: `External Link`, }); @@ -54,15 +52,19 @@ describe('pds-dropdown-menu-item', () => { } const shadowRoot = page.root.shadowRoot; - const linkElement = shadowRoot.querySelector('pds-link'); + const linkElement = shadowRoot.querySelector('a'); expect(linkElement).not.toBeNull(); - expect(linkElement?.href).toBe('https://example.com'); - expect(linkElement?.external).toBe(true); + expect(linkElement?.getAttribute('href')).toBe('https://example.com'); + expect(linkElement?.getAttribute('target')).toBe('_blank'); + expect(linkElement?.getAttribute('rel')).toBe('noopener noreferrer'); + // Check for external icon + const icon = shadowRoot.querySelector('pds-icon'); + expect(icon).not.toBeNull(); }); it('renders as a link with target="_blank" when both href and target are provided', async () => { const page = await newSpecPage({ - components: [PdsDropdownMenuItem, PdsLink], + components: [PdsDropdownMenuItem], html: `External Link`, }); @@ -71,15 +73,16 @@ describe('pds-dropdown-menu-item', () => { } const shadowRoot = page.root.shadowRoot; - const linkElement = shadowRoot.querySelector('pds-link'); + const linkElement = shadowRoot.querySelector('a'); expect(linkElement).not.toBeNull(); - expect(linkElement?.href).toBe('https://example.com'); - expect(linkElement?.target).toBe('_blank'); + expect(linkElement?.getAttribute('href')).toBe('https://example.com'); + expect(linkElement?.getAttribute('target')).toBe('_blank'); + expect(linkElement?.getAttribute('rel')).toBe('noopener noreferrer'); }); it('target prop takes precedence over external when both are set', async () => { const page = await newSpecPage({ - components: [PdsDropdownMenuItem, PdsLink], + components: [PdsDropdownMenuItem], html: `Link`, }); @@ -88,16 +91,17 @@ describe('pds-dropdown-menu-item', () => { } const shadowRoot = page.root.shadowRoot; - const linkElement = shadowRoot.querySelector('pds-link'); + const linkElement = shadowRoot.querySelector('a'); expect(linkElement).not.toBeNull(); - // Both props are passed through, pds-link handles precedence - expect(linkElement?.external).toBe(true); - expect(linkElement?.target).toBe('_self'); + // Target prop takes precedence over external's default _blank + expect(linkElement?.getAttribute('target')).toBe('_self'); + // No rel needed for _self target + expect(linkElement?.getAttribute('rel')).toBeNull(); }); - it('sets href to null when link is disabled', async () => { + it('sets href to undefined when link is disabled', async () => { const page = await newSpecPage({ - components: [PdsDropdownMenuItem, PdsLink], + components: [PdsDropdownMenuItem], html: `Disabled Link`, }); @@ -106,10 +110,10 @@ describe('pds-dropdown-menu-item', () => { } const shadowRoot = page.root.shadowRoot; - const linkElement = shadowRoot.querySelector('pds-link'); + const linkElement = shadowRoot.querySelector('a'); expect(linkElement).not.toBeNull(); - // The href should be null for disabled links - expect(linkElement?.getAttribute('href')).toBe(null); + // The href should not be set for disabled links + expect(linkElement?.hasAttribute('href')).toBe(false); }); it('applies disabled class and attributes when disabled', async () => { @@ -311,4 +315,209 @@ describe('pds-dropdown-menu-item', () => { expect(clickSpy).not.toHaveBeenCalled(); }); + + describe('Rails/Turbo compatibility props', () => { + it('does NOT add data attributes when props are not provided (backward compat)', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `Edit`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const linkElement = page.root.shadowRoot.querySelector('a'); + expect(linkElement).not.toBeNull(); + + // CRITICAL: No data attributes when props not provided + expect(linkElement?.hasAttribute('data-method')).toBe(false); + expect(linkElement?.hasAttribute('data-turbo-method')).toBe(false); + expect(linkElement?.hasAttribute('data-turbo-frame')).toBe(false); + expect(linkElement?.hasAttribute('data-turbo')).toBe(false); + }); + + it('adds data-method and data-turbo-method when httpMethod is provided', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `Delete`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const linkElement = page.root.shadowRoot.querySelector('a'); + expect(linkElement?.getAttribute('data-method')).toBe('delete'); + expect(linkElement?.getAttribute('data-turbo-method')).toBe('delete'); + }); + + it('adds data-turbo-frame when turboFrame is provided', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `Edit`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const linkElement = page.root.shadowRoot.querySelector('a'); + expect(linkElement?.getAttribute('data-turbo-frame')).toBe('_top'); + }); + + it('adds data-turbo when turbo prop is provided as false', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `Edit`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const linkElement = page.root.shadowRoot.querySelector('a'); + expect(linkElement?.getAttribute('data-turbo')).toBe('false'); + }); + + it('adds data-turbo when turbo prop is provided as true', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `Edit`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const linkElement = page.root.shadowRoot.querySelector('a'); + expect(linkElement?.getAttribute('data-turbo')).toBe('true'); + }); + + it('adds all data attributes when all props are provided', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `Delete`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const linkElement = page.root.shadowRoot.querySelector('a'); + expect(linkElement?.getAttribute('data-method')).toBe('delete'); + expect(linkElement?.getAttribute('data-turbo-method')).toBe('delete'); + expect(linkElement?.getAttribute('data-turbo-frame')).toBe('_top'); + expect(linkElement?.getAttribute('data-turbo')).toBe('false'); + }); + }); + + describe('pdsBeforeSubmit event', () => { + it('emits pdsBeforeSubmit before form submission for non-GET methods', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `Delete`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const beforeSubmitSpy = jest.fn(); + page.root.addEventListener('pdsBeforeSubmit', beforeSubmitSpy); + + // Click the link element directly + const linkElement = page.root.shadowRoot.querySelector('a'); + linkElement?.click(); + + expect(beforeSubmitSpy).toHaveBeenCalled(); + expect(beforeSubmitSpy.mock.calls[0][0].detail).toEqual({ + href: '/items/123', + method: 'delete', + }); + }); + + it('does NOT emit pdsBeforeSubmit for GET method', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `View`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const beforeSubmitSpy = jest.fn(); + page.root.addEventListener('pdsBeforeSubmit', beforeSubmitSpy); + + const linkElement = page.root.shadowRoot.querySelector('a'); + linkElement?.click(); + + // pdsBeforeSubmit should NOT be emitted for GET + expect(beforeSubmitSpy).not.toHaveBeenCalled(); + }); + + it('does NOT emit pdsBeforeSubmit when httpMethod is not set', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `View`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const beforeSubmitSpy = jest.fn(); + page.root.addEventListener('pdsBeforeSubmit', beforeSubmitSpy); + + const linkElement = page.root.shadowRoot.querySelector('a'); + linkElement?.click(); + + // pdsBeforeSubmit should NOT be emitted when httpMethod is not set + expect(beforeSubmitSpy).not.toHaveBeenCalled(); + }); + + it('emits pdsClick even when httpMethod is set (backward compat)', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `Delete`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const clickSpy = jest.fn(); + page.root.addEventListener('pdsClick', clickSpy); + + const linkElement = page.root.shadowRoot.querySelector('a'); + linkElement?.click(); + + // CRITICAL: pdsClick must still emit for Stimulus patterns to work + expect(clickSpy).toHaveBeenCalled(); + }); + + it('emits pdsBeforeSubmit for POST method', async () => { + const page = await newSpecPage({ + components: [PdsDropdownMenuItem], + html: `Duplicate`, + }); + + if (!page.root || !page.root.shadowRoot) { + fail('Root or shadow root not found'); + } + + const beforeSubmitSpy = jest.fn(); + page.root.addEventListener('pdsBeforeSubmit', beforeSubmitSpy); + + const linkElement = page.root.shadowRoot.querySelector('a'); + linkElement?.click(); + + expect(beforeSubmitSpy).toHaveBeenCalled(); + expect(beforeSubmitSpy.mock.calls[0][0].detail).toEqual({ + href: '/items/123/duplicate', + method: 'post', + }); + }); + }); }); diff --git a/libs/core/src/components/pds-link/readme.md b/libs/core/src/components/pds-link/readme.md index 39433d603..90fa2a760 100644 --- a/libs/core/src/components/pds-link/readme.md +++ b/libs/core/src/components/pds-link/readme.md @@ -34,10 +34,6 @@ Link is mainly used as navigational element and usually appear within or directl ## Dependencies -### Used by - - - [pds-dropdown-menu-item](../pds-dropdown-menu/pds-dropdown-menu-item) - ### Depends on - pds-icon @@ -46,7 +42,6 @@ Link is mainly used as navigational element and usually appear within or directl ```mermaid graph TD; pds-link --> pds-icon - pds-dropdown-menu-item --> pds-link style pds-link fill:#f9f,stroke:#333,stroke-width:4px ```