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": {