diff --git a/packages/react-library/lib/components/stencil-generated/components.ts b/packages/react-library/lib/components/stencil-generated/components.ts
index ac9b6d460..75db1445f 100644
--- a/packages/react-library/lib/components/stencil-generated/components.ts
+++ b/packages/react-library/lib/components/stencil-generated/components.ts
@@ -15,6 +15,7 @@ import { DnnChevron as DnnChevronElement, defineCustomElement as defineDnnChevro
import { DnnCollapsible as DnnCollapsibleElement, defineCustomElement as defineDnnCollapsible } from "@dnncommunity/dnn-elements/dist/components/dnn-collapsible.js";
import { DnnColorInput as DnnColorInputElement, defineCustomElement as defineDnnColorInput } from "@dnncommunity/dnn-elements/dist/components/dnn-color-input.js";
import { DnnColorPicker as DnnColorPickerElement, defineCustomElement as defineDnnColorPicker } from "@dnncommunity/dnn-elements/dist/components/dnn-color-picker.js";
+import { DnnContextMenu as DnnContextMenuElement, defineCustomElement as defineDnnContextMenu } from "@dnncommunity/dnn-elements/dist/components/dnn-context-menu.js";
import { DnnDropzone as DnnDropzoneElement, defineCustomElement as defineDnnDropzone } from "@dnncommunity/dnn-elements/dist/components/dnn-dropzone.js";
import { DnnExampleForm as DnnExampleFormElement, defineCustomElement as defineDnnExampleForm } from "@dnncommunity/dnn-elements/dist/components/dnn-example-form.js";
import { DnnFieldset as DnnFieldsetElement, defineCustomElement as defineDnnFieldset } from "@dnncommunity/dnn-elements/dist/components/dnn-fieldset.js";
@@ -140,6 +141,17 @@ export const DnnColorPicker: StencilReactComponent;
+
+export const DnnContextMenu: StencilReactComponent = /*@__PURE__*/ createComponent({
+ tagName: 'dnn-context-menu',
+ elementClass: DnnContextMenuElement,
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
+ react: React,
+ events: {} as DnnContextMenuEvents,
+ defineCustomElement: defineDnnContextMenu
+});
+
export type DnnDropzoneEvents = { onFilesSelected: EventName> };
export const DnnDropzone: StencilReactComponent = /*@__PURE__*/ createComponent({
diff --git a/packages/stencil-library/custom-elements.json b/packages/stencil-library/custom-elements.json
index 42ad95b00..61b5a0433 100644
--- a/packages/stencil-library/custom-elements.json
+++ b/packages/stencil-library/custom-elements.json
@@ -772,6 +772,60 @@
}
]
},
+ {
+ "kind": "javascript-module",
+ "path": "src/components/dnn-context-menu/dnn-context-menu.tsx",
+ "declarations": [
+ {
+ "kind": "class",
+ "name": "dnn-context-menu.tsx",
+ "tagName": "dnn-context-menu",
+ "description": "",
+ "attributes": [
+ {
+ "name": "close-on-click",
+ "type": {
+ "text": "boolean"
+ },
+ "description": "If true, the menu will close when an item is clicked.",
+ "default": "false",
+ "required": false
+ }
+ ],
+ "members": [
+ {
+ "kind": "method",
+ "name": "close",
+ "description": "Closes the menu.",
+ "signature": "close() => Promise"
+ },
+ {
+ "kind": "method",
+ "name": "open",
+ "description": "Opens the menu using a pointer event.",
+ "signature": "open(event: PointerEvent) => Promise"
+ }
+ ],
+ "events": [],
+ "slots": [],
+ "cssProperties": [
+ {
+ "name": "--color-background",
+ "description": "The background color of the context menu."
+ },
+ {
+ "name": "--color-border",
+ "description": "The border color of the context menu."
+ },
+ {
+ "name": "--padding",
+ "description": "The padding inside the context menu."
+ }
+ ],
+ "cssParts": []
+ }
+ ]
+ },
{
"kind": "javascript-module",
"path": "src/components/dnn-dropzone/dnn-dropzone.tsx",
diff --git a/packages/stencil-library/licenses.json b/packages/stencil-library/licenses.json
index 70485bc87..1f3e2f516 100644
--- a/packages/stencil-library/licenses.json
+++ b/packages/stencil-library/licenses.json
@@ -1,7 +1,7 @@
{
"@dnncommunity/dnn-elements@0.28.0-beta.2": {
"licenses": "MIT",
- "repository": "https://github.com/dnncommunity/dnn-elements",
+ "repository": "https://github.com/DNNCommunity/dnn-elements",
"path": "",
"licenseFile": "D:\\dnn-elements\\packages\\stencil-library\\README.md"
},
diff --git a/packages/stencil-library/src/components.d.ts b/packages/stencil-library/src/components.d.ts
index ff69b58d2..608b0cbc4 100644
--- a/packages/stencil-library/src/components.d.ts
+++ b/packages/stencil-library/src/components.d.ts
@@ -300,6 +300,21 @@ export namespace Components {
*/
"colorBoxHeight": string;
}
+ interface DnnContextMenu {
+ /**
+ * Closes the menu.
+ */
+ "close": () => Promise;
+ /**
+ * If true, the menu will close when an item is clicked.
+ * @default false
+ */
+ "closeOnClick": boolean;
+ /**
+ * Opens the menu using a pointer event.
+ */
+ "open": (event: PointerEvent) => Promise;
+ }
interface DnnDropzone {
/**
* If true, will allow the user to take a snapshot using the device camera. (only works over https).
@@ -1066,6 +1081,12 @@ declare global {
prototype: HTMLDnnColorPickerElement;
new (): HTMLDnnColorPickerElement;
};
+ interface HTMLDnnContextMenuElement extends Components.DnnContextMenu, HTMLStencilElement {
+ }
+ var HTMLDnnContextMenuElement: {
+ prototype: HTMLDnnContextMenuElement;
+ new (): HTMLDnnContextMenuElement;
+ };
interface HTMLDnnDropzoneElementEventMap {
"filesSelected": File[];
}
@@ -1377,6 +1398,7 @@ declare global {
"dnn-collapsible": HTMLDnnCollapsibleElement;
"dnn-color-input": HTMLDnnColorInputElement;
"dnn-color-picker": HTMLDnnColorPickerElement;
+ "dnn-context-menu": HTMLDnnContextMenuElement;
"dnn-dropzone": HTMLDnnDropzoneElement;
"dnn-example-form": HTMLDnnExampleFormElement;
"dnn-fieldset": HTMLDnnFieldsetElement;
@@ -1733,6 +1755,13 @@ declare namespace LocalJSX {
*/
"onColorChanged"?: (event: DnnColorPickerCustomEvent) => void;
}
+ interface DnnContextMenu {
+ /**
+ * If true, the menu will close when an item is clicked.
+ * @default false
+ */
+ "closeOnClick"?: boolean;
+ }
interface DnnDropzone {
/**
* If true, will allow the user to take a snapshot using the device camera. (only works over https).
@@ -2392,6 +2421,9 @@ declare namespace LocalJSX {
"color": string;
"colorBoxHeight": string;
}
+ interface DnnContextMenuAttributes {
+ "closeOnClick": boolean;
+ }
interface DnnDropzoneAttributes {
"allowCameraMode": boolean;
"captureQuality": number;
@@ -2515,6 +2547,7 @@ declare namespace LocalJSX {
"dnn-collapsible": Omit & { [K in keyof DnnCollapsible & keyof DnnCollapsibleAttributes]?: DnnCollapsible[K] } & { [K in keyof DnnCollapsible & keyof DnnCollapsibleAttributes as `attr:${K}`]?: DnnCollapsibleAttributes[K] } & { [K in keyof DnnCollapsible & keyof DnnCollapsibleAttributes as `prop:${K}`]?: DnnCollapsible[K] };
"dnn-color-input": Omit & { [K in keyof DnnColorInput & keyof DnnColorInputAttributes]?: DnnColorInput[K] } & { [K in keyof DnnColorInput & keyof DnnColorInputAttributes as `attr:${K}`]?: DnnColorInputAttributes[K] } & { [K in keyof DnnColorInput & keyof DnnColorInputAttributes as `prop:${K}`]?: DnnColorInput[K] };
"dnn-color-picker": Omit & { [K in keyof DnnColorPicker & keyof DnnColorPickerAttributes]?: DnnColorPicker[K] } & { [K in keyof DnnColorPicker & keyof DnnColorPickerAttributes as `attr:${K}`]?: DnnColorPickerAttributes[K] } & { [K in keyof DnnColorPicker & keyof DnnColorPickerAttributes as `prop:${K}`]?: DnnColorPicker[K] };
+ "dnn-context-menu": Omit & { [K in keyof DnnContextMenu & keyof DnnContextMenuAttributes]?: DnnContextMenu[K] } & { [K in keyof DnnContextMenu & keyof DnnContextMenuAttributes as `attr:${K}`]?: DnnContextMenuAttributes[K] } & { [K in keyof DnnContextMenu & keyof DnnContextMenuAttributes as `prop:${K}`]?: DnnContextMenu[K] };
"dnn-dropzone": Omit & { [K in keyof DnnDropzone & keyof DnnDropzoneAttributes]?: DnnDropzone[K] } & { [K in keyof DnnDropzone & keyof DnnDropzoneAttributes as `attr:${K}`]?: DnnDropzoneAttributes[K] } & { [K in keyof DnnDropzone & keyof DnnDropzoneAttributes as `prop:${K}`]?: DnnDropzone[K] };
"dnn-example-form": DnnExampleForm;
"dnn-fieldset": Omit & { [K in keyof DnnFieldset & keyof DnnFieldsetAttributes]?: DnnFieldset[K] } & { [K in keyof DnnFieldset & keyof DnnFieldsetAttributes as `attr:${K}`]?: DnnFieldsetAttributes[K] } & { [K in keyof DnnFieldset & keyof DnnFieldsetAttributes as `prop:${K}`]?: DnnFieldset[K] };
@@ -2554,6 +2587,7 @@ declare module "@stencil/core" {
* Color Picker for Dnn
*/
"dnn-color-picker": LocalJSX.IntrinsicElements["dnn-color-picker"] & JSXBase.HTMLAttributes;
+ "dnn-context-menu": LocalJSX.IntrinsicElements["dnn-context-menu"] & JSXBase.HTMLAttributes;
"dnn-dropzone": LocalJSX.IntrinsicElements["dnn-dropzone"] & JSXBase.HTMLAttributes;
/**
* Do not use this component in production, it is meant for testing purposes only and is not distributed in the production package.
diff --git a/packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.scss b/packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.scss
new file mode 100644
index 000000000..16fba7f9e
--- /dev/null
+++ b/packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.scss
@@ -0,0 +1,24 @@
+:host {
+ /** @prop --color-background: The background color of the context menu. */
+ --color-background: var(--dnn-color-background, white);
+
+ /** @prop --color-border: The border color of the context menu. */
+ --color-border: var(--dnn-color-foreground, black);
+
+ /** @prop --padding: The padding inside the context menu. */
+ --padding: 0.25rem;
+
+ display: none;
+ flex-direction: column;
+ position: fixed;
+ z-index: 1;
+ background-color: var(--color-background);
+ border: 1px solid var(--color-border);
+ padding: var(--padding);
+}
+
+::slotted(*) {
+ display: flex;
+ width: 100%;
+ white-space: nowrap;
+}
diff --git a/packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.stories.ts b/packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.stories.ts
new file mode 100644
index 000000000..1e6eb830a
--- /dev/null
+++ b/packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.stories.ts
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import { html } from 'lit';
+import readme from "./readme.md?raw";
+
+const meta: Meta = {
+ title: 'Elements/ContextMenu',
+ component: 'dnn-context-menu',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component: readme,
+ }
+ }
+ },
+ argTypes: {
+ closeOnClick: { control: 'boolean' },
+ },
+};
+export default meta;
+
+const Template = (args) => html`
+
+
+
+
+
Action 1
+
Action 2
+
Action 3
+
+
+
+
+`;
+
+type Story = StoryObj;
+
+export const Primary: Story = Template.bind({});
+Primary.args = {
+ closeOnClick: true,
+};
diff --git a/packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.tsx b/packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.tsx
new file mode 100644
index 000000000..33ea84694
--- /dev/null
+++ b/packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.tsx
@@ -0,0 +1,167 @@
+import { Component, Host, State, Method, h, Element, Listen, Prop } from '@stencil/core';
+
+@Component({
+ tag: 'dnn-context-menu',
+ styleUrl: 'dnn-context-menu.scss',
+ shadow: true,
+})
+export class DnnContextMenu {
+ /** If true, the menu will close when an item is clicked. */
+ @Prop() closeOnClick = false;
+
+ /** Opens the menu using a pointer event. */
+ @Method()
+ async open(event: PointerEvent) {
+ await this.handleOpen(event);
+ }
+
+ /** Closes the menu. */
+ @Method()
+ async close(){
+ await this.handleClose();
+ }
+
+ @Element() el!: HTMLElement;
+
+ @State() isOpen = false;
+ @State() position = { x: 0, y: 0 };
+ @State() positioned = false;
+
+ // Close when clicking outside the menu.
+ @Listen('mousedown', { target: 'window', capture: true })
+ onWindowMouseDown(ev: MouseEvent) {
+ if (!this.isOpen) return;
+
+ const path = ev.composedPath() as EventTarget[];
+ const clickedInside = path.includes(this.el);
+ if (!clickedInside) this.close();
+ }
+
+ // Close on scroll of the window.
+ @Listen('scroll', { target: 'window', capture: true })
+ onWindowScroll() {
+ if (this.isOpen) this.close();
+ }
+
+ // Close on window resize.
+ @Listen('resize', { target: 'window' })
+ onWindowResize() {
+ if (this.isOpen) this.close();
+ }
+
+ // Close on Escape key.
+ @Listen('keydown', { target: 'window' })
+ onWindowKeyDown(ev: KeyboardEvent) {
+ if (!this.isOpen) return;
+
+ if (ev.key === 'Escape') {
+ ev.preventDefault();
+ this.close();
+ return;
+ }
+ }
+
+ private async handleOpen(event: PointerEvent){
+ // Open first so slot content renders and we can measure it.
+ this.isOpen = true;
+ // Ensure we start hidden while measuring to avoid flashing
+ this.positioned = false;
+
+ // Determine initial origin point (viewport coordinates)
+ let originX = 0;
+ let originY = 0;
+ const usedPointer = event.button === 2;
+
+ if (usedPointer) {
+ // Right click was used, so position the menu at the pointer location
+ originX = event.clientX;
+ originY = event.clientY;
+ } else {
+ // Keyboard was used, so position the menu relative to the source element.
+ const targetRect = (event.target as HTMLElement).getBoundingClientRect();
+ originX = targetRect.left;
+ originY = targetRect.bottom;
+ }
+
+ // Set a provisional position so the menu renders and can be measured.
+ this.position = { x: originX, y: originY };
+
+ // Wait a frame to ensure the element is rendered and layouted.
+ await new Promise(requestAnimationFrame);
+
+ // Measure the menu
+ const menuRect = this.el.getBoundingClientRect();
+ const menuWidth = menuRect.width;
+ const menuHeight = menuRect.height;
+
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+
+ let x = originX;
+ let y = originY;
+
+ // If opening to the right would overflow the viewport, open to the left instead.
+ if (x + menuWidth > vw) {
+ if (usedPointer) {
+ x = Math.max(0, originX - menuWidth);
+ } else {
+ // For keyboard anchoring, align the menu's right edge with the target's right edge.
+ const targetRect = (event.target as HTMLElement).getBoundingClientRect();
+ x = Math.max(0, targetRect.right - menuWidth);
+ }
+ }
+
+ // If opening downward would overflow the viewport, open upward instead.
+ if (y + menuHeight > vh) {
+ if (usedPointer) {
+ y = Math.max(0, originY - menuHeight);
+ } else {
+ const targetRect = (event.target as HTMLElement).getBoundingClientRect();
+ y = Math.max(0, targetRect.top - menuHeight);
+ }
+ }
+
+ // Final clamp to viewport in case of extreme sizes
+ x = Math.max(0, Math.min(x, Math.max(0, vw - menuWidth)));
+ y = Math.max(0, Math.min(y, Math.max(0, vh - menuHeight)));
+
+ this.position = { x, y };
+
+ // Ensure the browser painted the new position then show with a transition
+ await new Promise(requestAnimationFrame);
+ this.positioned = true;
+ }
+
+ private async handleClose() {
+ this.positioned = false;
+ // Wait for the opacity transition to finish before hiding the element
+ await new Promise(resolve => setTimeout(resolve, 160));
+ this.isOpen = false;
+ }
+
+ private async handleMenuClick() {
+ if (this.closeOnClick) {
+ await this.handleClose();
+ }
+ }
+
+ render() {
+ return (
+ void this.handleMenuClick()}
+ >
+
+
+ );
+ }
+}
diff --git a/packages/stencil-library/src/components/dnn-context-menu/readme.md b/packages/stencil-library/src/components/dnn-context-menu/readme.md
new file mode 100644
index 000000000..b627968f3
--- /dev/null
+++ b/packages/stencil-library/src/components/dnn-context-menu/readme.md
@@ -0,0 +1,131 @@
+# dnn-context-menu
+Can be used to display a context menu.
+The contents of the menu as entirely up to the user.
+Items insile of `dnn-context-menu` that can be activated should have `role="menuitem"` or similar for accessibility reasons.
+
+
+
+
+
+## Usage
+
+### HTML
+
+### Most basic usage
+
+```html
+
+
+
+
+
+
Action 1
+
Action 2
+
Action 3
+
+```
+
+Notes:
+- Use `menu.open(event)` to open the menu from a pointer event.
+- Use `menu.close()` to programatically close the menu if needed.
+
+
+### JSX-TSX
+
+### Most basic usage (JSX / TSX)
+
+```tsx
+// In a Stencil / React / Preact environment you can call the component methods
+// by querying the element reference and calling `open` with a PointerEvent.
+
+private handleOpenMenu(e) {
+ e.preventDefault();
+ const menu = document.querySelector('#menu') as any;
+ menu.open(e as PointerEvent);
+}
+
+render() {
+ return (
+
+ );
+}
+```
+
+Notes:
+- In frameworks you can also keep a ref to the element instead of querySelector.
+- `close-on-click` is a convenience boolean that will call `close()` when an item is clicked. You can instead call close() yourself if you need programatic control over closing.
+
+
+
+## Properties
+
+| Property | Attribute | Description | Type | Default |
+| -------------- | ---------------- | ----------------------------------------------------- | --------- | ------- |
+| `closeOnClick` | `close-on-click` | If true, the menu will close when an item is clicked. | `boolean` | `false` |
+
+
+## Methods
+
+### `close() => Promise`
+
+Closes the menu.
+
+#### Returns
+
+Type: `Promise`
+
+
+
+### `open(event: PointerEvent) => Promise`
+
+Opens the menu using a pointer event.
+
+#### Parameters
+
+| Name | Type | Description |
+| ------- | -------------- | ----------- |
+| `event` | `PointerEvent` | |
+
+#### Returns
+
+Type: `Promise`
+
+
+
+
+## CSS Custom Properties
+
+| Name | Description |
+| -------------------- | ----------------------------------------- |
+| `--color-background` | The background color of the context menu. |
+| `--color-border` | The border color of the context menu. |
+| `--padding` | The padding inside the context menu. |
+
+
+----------------------------------------------
+
+*Built with [StencilJS](https://stenciljs.com/)*
diff --git a/packages/stencil-library/src/components/dnn-context-menu/usage/HTML.md b/packages/stencil-library/src/components/dnn-context-menu/usage/HTML.md
new file mode 100644
index 000000000..b60b79e86
--- /dev/null
+++ b/packages/stencil-library/src/components/dnn-context-menu/usage/HTML.md
@@ -0,0 +1,31 @@
+### Most basic usage
+
+```html
+
+
+
+
+
+
Action 1
+
Action 2
+
Action 3
+
+```
+
+Notes:
+- Use `menu.open(event)` to open the menu from a pointer event.
+- Use `menu.close()` to programatically close the menu if needed.
diff --git a/packages/stencil-library/src/components/dnn-context-menu/usage/JSX-TSX.md b/packages/stencil-library/src/components/dnn-context-menu/usage/JSX-TSX.md
new file mode 100644
index 000000000..3f0e772ed
--- /dev/null
+++ b/packages/stencil-library/src/components/dnn-context-menu/usage/JSX-TSX.md
@@ -0,0 +1,32 @@
+### Most basic usage (JSX / TSX)
+
+```tsx
+// In a Stencil / React / Preact environment you can call the component methods
+// by querying the element reference and calling `open` with a PointerEvent.
+
+private handleOpenMenu(e) {
+ e.preventDefault();
+ const menu = document.querySelector('#menu') as any;
+ menu.open(e as PointerEvent);
+}
+
+render() {
+ return (
+
+ );
+}
+```
+
+Notes:
+- In frameworks you can also keep a ref to the element instead of querySelector.
+- `close-on-click` is a convenience boolean that will call `close()` when an item is clicked. You can instead call close() yourself if you need programatic control over closing.
diff --git a/packages/stencil-library/src/index.html b/packages/stencil-library/src/index.html
index c2b06f09b..86d8600d8 100644
--- a/packages/stencil-library/src/index.html
+++ b/packages/stencil-library/src/index.html
@@ -230,7 +230,7 @@