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`
event.stopPropagation()}> -
${recipient.name}
- ${recipientRoleDefinitions.map((roleDefinition) => html``)} -
`; + return html` + ) => this.handleRecipientContextMenuAction(event, recipient)} + > + `; } private renderStepper(): TemplateResult {