From 2d0525f208d56750621002cbc5e435f0493be384 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 22 Feb 2026 16:51:32 -0500 Subject: [PATCH] Added a new dnn-context-menu component This component is basically just a container that can be opened at a given location, it will handle closing itself on click outside of it and optionally clicks inside of it. The contents are entirely up to the user of the component, it would be links, buttons or an entire complex UI. --- .../stencil-generated/components.ts | 12 ++ packages/stencil-library/custom-elements.json | 54 ++++++ packages/stencil-library/licenses.json | 2 +- packages/stencil-library/src/components.d.ts | 34 ++++ .../dnn-context-menu/dnn-context-menu.scss | 24 +++ .../dnn-context-menu.stories.ts | 55 ++++++ .../dnn-context-menu/dnn-context-menu.tsx | 167 ++++++++++++++++++ .../src/components/dnn-context-menu/readme.md | 131 ++++++++++++++ .../components/dnn-context-menu/usage/HTML.md | 31 ++++ .../dnn-context-menu/usage/JSX-TSX.md | 32 ++++ packages/stencil-library/src/index.html | 40 ++++- packages/stencil-library/vscode-data.json | 13 ++ 12 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.scss create mode 100644 packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.stories.ts create mode 100644 packages/stencil-library/src/components/dnn-context-menu/dnn-context-menu.tsx create mode 100644 packages/stencil-library/src/components/dnn-context-menu/readme.md create mode 100644 packages/stencil-library/src/components/dnn-context-menu/usage/HTML.md create mode 100644 packages/stencil-library/src/components/dnn-context-menu/usage/JSX-TSX.md 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 @@

Dnn HTML custom elements

  • dnn-checkbox
  • dnn-chevron
  • dnn-collapsible
  • - dnn-color-input +
  • dnn-color-input
  • dnn-input
  • dnn-modal
  • dnn-searchbox
  • @@ -246,6 +246,7 @@

    Dnn HTML custom elements

  • dnn-permissions-grid
  • dnn-richtext
  • dnn-example-form
  • +
  • dnn-context-menu
  • @@ -281,6 +282,43 @@

    dnn-autocomplete

    + + +
    +

    dnn-context-menu

    +

    + The button below has a context menu, there are 2 ways to open it: +

      +
    • Right click on the button
    • +
    • Focus it using your keyboard and then press the context menu key on your keyboard.
    • +
    +

    + + + Right click me or press the context menu key when I'm focused + + Item 1 + Item 2 + Item 3 is a longer item + + + +
    +
    diff --git a/packages/stencil-library/vscode-data.json b/packages/stencil-library/vscode-data.json index 248280f74..fb7769de7 100644 --- a/packages/stencil-library/vscode-data.json +++ b/packages/stencil-library/vscode-data.json @@ -294,6 +294,19 @@ } ] }, + { + "name": "dnn-context-menu", + "description": { + "kind": "markdown", + "value": "" + }, + "attributes": [ + { + "name": "close-on-click", + "description": "If true, the menu will close when an item is clicked." + } + ] + }, { "name": "dnn-dropzone", "description": {