diff --git a/changelog.md b/changelog.md
index d3818f0..339df6a 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,12 @@
# Changelog
+## 2026-05-02 - 1.5.0 - feat(elements)
+add reusable context menu element for recipient role selection
+
+- introduces a new sdig-contextmenu web component with configurable actions, selection state, and viewport-aware positioning
+- exports the new context menu from the shared elements index
+- refactors workspace compose to use the reusable context menu for recipient role changes while preserving signer role safeguards
+
## 2026-05-02 - 1.4.0 - feat(workspace-compose)
add recipient routing roles and drag-and-drop routing management
diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts
index c5008b9..98adb47 100644
--- a/ts_web/00_commitinfo_data.ts
+++ b/ts_web/00_commitinfo_data.ts
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@signature.digital/catalog',
- version: '1.4.0',
+ version: '1.5.0',
description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
}
diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts
index 64e91cb..9ab69e1 100644
--- a/ts_web/elements/index.ts
+++ b/ts_web/elements/index.ts
@@ -1,4 +1,5 @@
// Signature components
+export * from './sdig-contextmenu/index.js';
export * from './sdig-signbox/index.js';
export * from './sdig-signpad/index.js';
diff --git a/ts_web/elements/sdig-contextmenu/index.ts b/ts_web/elements/sdig-contextmenu/index.ts
new file mode 100644
index 0000000..f2f0146
--- /dev/null
+++ b/ts_web/elements/sdig-contextmenu/index.ts
@@ -0,0 +1 @@
+export * from './sdig-contextmenu.js';
diff --git a/ts_web/elements/sdig-contextmenu/sdig-contextmenu.ts b/ts_web/elements/sdig-contextmenu/sdig-contextmenu.ts
new file mode 100644
index 0000000..21c2100
--- /dev/null
+++ b/ts_web/elements/sdig-contextmenu/sdig-contextmenu.ts
@@ -0,0 +1,234 @@
+import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
+
+export interface ISdigContextMenuAction {
+ id: string;
+ label: string;
+ description?: string;
+ selected?: boolean;
+ disabled?: boolean;
+ danger?: boolean;
+}
+
+export interface ISdigContextMenuActionEventDetail {
+ id: string;
+ action: ISdigContextMenuAction;
+}
+
+type TMenuPosition = {
+ x: number;
+ y: number;
+ ready: boolean;
+};
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'sdig-contextmenu': SdigContextmenu;
+ }
+}
+
+@customElement('sdig-contextmenu')
+export class SdigContextmenu extends DeesElement {
+ public static demo = () => html`
+
+
+
+ `;
+ public static demoGroups = ['Signature Digital Primitives'];
+
+ @property({ type: Number }) public accessor anchorX: number = 0;
+ @property({ type: Number }) public accessor anchorY: number = 0;
+ @property({ type: String }) public accessor title: string = '';
+ @property({ attribute: false }) public accessor actions: ISdigContextMenuAction[] = [];
+ @state() private accessor position: TMenuPosition = { x: 0, y: 0, ready: false };
+
+ private positionUpdateFrame: number | null = null;
+
+ public static styles = css`
+ :host { display: contents; }
+
+ .menu {
+ position: fixed;
+ z-index: 1000;
+ min-width: 190px;
+ max-width: min(280px, calc(100vw - 16px));
+ padding: 6px;
+ border: 1px solid var(--border, hsl(0 0% 14.9%));
+ border-radius: 8px;
+ background: var(--bg-card, hsl(0 0% 7%));
+ color: var(--text, hsl(0 0% 98%));
+ box-shadow: 0 16px 42px rgba(0,0,0,0.36);
+ box-sizing: border-box;
+ }
+
+ .title {
+ padding: 7px 8px;
+ margin-bottom: 4px;
+ border-bottom: 1px solid var(--border-subtle, hsl(0 0% 11%));
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-sec, hsl(0 0% 63.9%));
+ }
+
+ .action {
+ width: 100%;
+ min-height: 34px;
+ padding: 8px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-sec, hsl(0 0% 63.9%));
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ text-align: left;
+ font: inherit;
+ font-size: 11px;
+ cursor: pointer;
+ }
+
+ .action:hover { background: var(--hover, rgba(255,255,255,0.06)); color: var(--text, hsl(0 0% 98%)); }
+ .action.danger { color: var(--error, #ef4444); }
+ .action[disabled] { opacity: 0.45; cursor: not-allowed; }
+ .action[disabled]:hover { background: transparent; color: var(--text-sec, hsl(0 0% 63.9%)); }
+
+ .action-mark {
+ width: 12px;
+ height: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+
+ .action-mark.selected::before {
+ content: '';
+ width: 7px;
+ height: 4px;
+ border-left: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: rotate(-45deg) translate(1px, -1px);
+ }
+
+ .action-copy {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ .action-label {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .action-description {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: var(--text-muted, hsl(0 0% 48%));
+ font-size: 10px;
+ line-height: 1.25;
+ }
+ `;
+
+ public connectedCallback = async () => {
+ await super.connectedCallback();
+ window.addEventListener('resize', this.queuePositionUpdate);
+ };
+
+ public disconnectedCallback = async () => {
+ window.removeEventListener('resize', this.queuePositionUpdate);
+ if (this.positionUpdateFrame !== null) {
+ globalThis.cancelAnimationFrame(this.positionUpdateFrame);
+ this.positionUpdateFrame = null;
+ }
+ await super.disconnectedCallback();
+ };
+
+ public updated() {
+ this.queuePositionUpdate();
+ }
+
+ private queuePositionUpdate = () => {
+ if (this.positionUpdateFrame !== null) return;
+ this.positionUpdateFrame = globalThis.requestAnimationFrame(() => {
+ this.positionUpdateFrame = null;
+ this.positionMenu();
+ });
+ };
+
+ private positionMenu() {
+ const menu = this.shadowRoot?.querySelector('.menu') as HTMLElement | null;
+ if (!menu) return;
+
+ const margin = 8;
+ const gap = 4;
+ const rect = menu.getBoundingClientRect();
+ const viewportWidth = globalThis.innerWidth;
+ const viewportHeight = globalThis.innerHeight;
+ const spaceRight = viewportWidth - this.anchorX - margin;
+ const spaceLeft = this.anchorX - margin;
+ const spaceBelow = viewportHeight - this.anchorY - margin;
+ const spaceAbove = this.anchorY - margin;
+ let x = this.anchorX + gap;
+ let y = this.anchorY + gap;
+
+ if (spaceRight < rect.width + gap && spaceLeft > spaceRight) {
+ x = this.anchorX - rect.width - gap;
+ }
+
+ if (spaceBelow < rect.height + gap && spaceAbove > spaceBelow) {
+ y = this.anchorY - rect.height - gap;
+ }
+
+ const maxX = Math.max(margin, viewportWidth - rect.width - margin);
+ const maxY = Math.max(margin, viewportHeight - rect.height - margin);
+ const nextPosition = {
+ x: Math.round(Math.max(margin, Math.min(maxX, x))),
+ y: Math.round(Math.max(margin, Math.min(maxY, y))),
+ ready: true,
+ };
+
+ if (this.position.x !== nextPosition.x || this.position.y !== nextPosition.y || this.position.ready !== nextPosition.ready) {
+ this.position = nextPosition;
+ }
+ }
+
+ private selectAction(action: ISdigContextMenuAction) {
+ if (action.disabled) return;
+ this.dispatchEvent(new CustomEvent('contextmenu-action', {
+ detail: { id: action.id, action },
+ bubbles: true,
+ composed: true,
+ }));
+ }
+
+ public render(): TemplateResult {
+ const x = this.position.ready ? this.position.x : this.anchorX;
+ const y = this.position.ready ? this.position.y : this.anchorY;
+ return html`
+
+ `;
+ }
+}
diff --git a/ts_web/elements/sdig-workspace/sdig-workspace-compose.ts b/ts_web/elements/sdig-workspace/sdig-workspace-compose.ts
index f4fe264..e7e9eec 100644
--- a/ts_web/elements/sdig-workspace/sdig-workspace-compose.ts
+++ b/ts_web/elements/sdig-workspace/sdig-workspace-compose.ts
@@ -1,5 +1,7 @@
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
import { actionButton, demoFields, demoRecipients, fakeDocument, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement, type IRecipient, type TRecipientRole } from './sdig-workspace.shared.js';
+import '../sdig-contextmenu/index.js';
+import { type ISdigContextMenuAction, type ISdigContextMenuActionEventDetail } from '../sdig-contextmenu/index.js';
declare global {
interface HTMLElementTagNameMap {
@@ -109,11 +111,6 @@ export class SdigWorkspaceCompose extends DeesElement {
.signing-placeholder { position: absolute; left: 0; right: 0; top: var(--routing-top); height: var(--routing-row-height); border: 1.5px dashed var(--accent); border-radius: 6px; background: transparent; pointer-events: none; transition: top 0.16s ease; }
.signing-drag-overlay { position: absolute; left: 0; right: 0; z-index: 6; top: var(--routing-top); margin-bottom: 0; cursor: grabbing; pointer-events: none; border-color: var(--accent); box-shadow: 0 10px 28px rgba(0,0,0,0.28); transform: scale(1.015); }
.role-hint { margin-top: -2px; margin-bottom: 10px; font-size: 10px; line-height: 1.45; color: var(--text-muted); }
- .recipient-context-menu { position: fixed; z-index: 100; min-width: 190px; padding: 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); box-shadow: 0 16px 42px rgba(0,0,0,0.36); }
- .recipient-context-title { padding: 7px 8px; font-size: 11px; font-weight: 700; color: var(--text-sec); border-bottom: 1px solid var(--border-subtle); margin-bottom: 4px; }
- .context-action { width: 100%; padding: 8px; border-radius: 6px; background: transparent; color: var(--text-sec); display: flex; align-items: center; gap: 8px; text-align: left; font-size: 11px; }
- .context-action:hover { background: var(--hover); color: var(--text); }
- .context-action[disabled] { opacity: 0.45; cursor: not-allowed; }
.page-drop-target { outline: 1px dashed transparent; outline-offset: 8px; }
.page-drop-target.drag-over { outline-color: var(--accent); }
.field-box { user-select: none; touch-action: none; }
@@ -247,6 +244,23 @@ export class SdigWorkspaceCompose extends DeesElement {
this.recipientContextMenu = null;
};
+ private recipientContextMenuActions(recipient: IRecipient): ISdigContextMenuAction[] {
+ const signerCount = this.signingRecipients().length;
+ return recipientRoleDefinitions.map((roleDefinition) => ({
+ id: roleDefinition.role,
+ label: roleDefinition.label,
+ selected: recipient.role === roleDefinition.role,
+ disabled: recipient.role === 'signer' && roleDefinition.role !== 'signer' && signerCount <= 1,
+ }));
+ }
+
+ private handleRecipientContextMenuAction(event: CustomEvent, recipient: IRecipient) {
+ const role = event.detail.id as TRecipientRole;
+ if (!recipientRoleDefinitions.some((roleDefinition) => roleDefinition.role === role)) return;
+ this.updateRecipientRole(recipient.id, role);
+ this.closeRecipientContextMenu();
+ }
+
private handleDocumentClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest('.field-box')) return;
@@ -528,11 +542,15 @@ export class SdigWorkspaceCompose extends DeesElement {
if (!this.recipientContextMenu) return html``;
const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === this.recipientContextMenu?.recipientId);
if (!recipient) return html``;
- const signerCount = this.signingRecipients().length;
- return html``;
+ return html`
+ ) => this.handleRecipientContextMenuAction(event, recipient)}
+ >
+ `;
}
private renderStepper(): TemplateResult {