feat(elements): add reusable context menu element for recipient role selection
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-05-02 - 1.4.0 - feat(workspace-compose)
|
||||||
add recipient routing roles and drag-and-drop routing management
|
add recipient routing roles and drag-and-drop routing management
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@signature.digital/catalog',
|
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.'
|
description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Signature components
|
// Signature components
|
||||||
|
export * from './sdig-contextmenu/index.js';
|
||||||
export * from './sdig-signbox/index.js';
|
export * from './sdig-signbox/index.js';
|
||||||
export * from './sdig-signpad/index.js';
|
export * from './sdig-signpad/index.js';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './sdig-contextmenu.js';
|
||||||
@@ -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`
|
||||||
|
<div style="position: relative; min-height: 260px; padding: 24px; --bg-card: hsl(0 0% 7%); --bg-input: hsl(0 0% 9%); --border: hsl(0 0% 14.9%); --border-subtle: hsl(0 0% 11%); --text: hsl(0 0% 98%); --text-sec: hsl(0 0% 63.9%); --text-muted: hsl(0 0% 48%); --hover: rgba(255,255,255,0.06); --error: #ef4444;">
|
||||||
|
<sdig-contextmenu
|
||||||
|
.anchorX=${80}
|
||||||
|
.anchorY=${70}
|
||||||
|
.title=${'Recipient'}
|
||||||
|
.actions=${[
|
||||||
|
{ id: 'signer', label: 'Needs signature', selected: true },
|
||||||
|
{ id: 'copy', label: 'Final copy only' },
|
||||||
|
{ id: 'updates', label: 'Every step update' },
|
||||||
|
]}
|
||||||
|
></sdig-contextmenu>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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<ISdigContextMenuActionEventDetail>('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`
|
||||||
|
<div class="menu" style="left: ${x}px; top: ${y}px; visibility: ${this.position.ready ? 'visible' : 'hidden'};" @click=${(event: Event) => event.stopPropagation()} @contextmenu=${(event: Event) => event.preventDefault()}>
|
||||||
|
${this.title ? html`<div class="title">${this.title}</div>` : ''}
|
||||||
|
${this.actions.map((action) => html`
|
||||||
|
<button class="action ${action.danger ? 'danger' : ''}" ?disabled=${action.disabled} @click=${() => this.selectAction(action)}>
|
||||||
|
<span class="action-mark ${action.selected ? 'selected' : ''}"></span>
|
||||||
|
<span class="action-copy">
|
||||||
|
<span class="action-label">${action.label}</span>
|
||||||
|
${action.description ? html`<span class="action-description">${action.description}</span>` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
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 { 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 {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
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-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); }
|
.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); }
|
.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 { outline: 1px dashed transparent; outline-offset: 8px; }
|
||||||
.page-drop-target.drag-over { outline-color: var(--accent); }
|
.page-drop-target.drag-over { outline-color: var(--accent); }
|
||||||
.field-box { user-select: none; touch-action: none; }
|
.field-box { user-select: none; touch-action: none; }
|
||||||
@@ -247,6 +244,23 @@ export class SdigWorkspaceCompose extends DeesElement {
|
|||||||
this.recipientContextMenu = null;
|
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<ISdigContextMenuActionEventDetail>, 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) => {
|
private handleDocumentClick = (event: MouseEvent) => {
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (target?.closest('.field-box')) return;
|
if (target?.closest('.field-box')) return;
|
||||||
@@ -528,11 +542,15 @@ export class SdigWorkspaceCompose extends DeesElement {
|
|||||||
if (!this.recipientContextMenu) return html``;
|
if (!this.recipientContextMenu) return html``;
|
||||||
const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === this.recipientContextMenu?.recipientId);
|
const recipient = this.recipients.find((currentRecipient) => currentRecipient.id === this.recipientContextMenu?.recipientId);
|
||||||
if (!recipient) return html``;
|
if (!recipient) return html``;
|
||||||
const signerCount = this.signingRecipients().length;
|
return html`
|
||||||
return html`<div class="recipient-context-menu" style="left: ${this.recipientContextMenu.x}px; top: ${this.recipientContextMenu.y}px;" @click=${(event: Event) => event.stopPropagation()}>
|
<sdig-contextmenu
|
||||||
<div class="recipient-context-title">${recipient.name}</div>
|
.anchorX=${this.recipientContextMenu.x}
|
||||||
${recipientRoleDefinitions.map((roleDefinition) => html`<button class="context-action" ?disabled=${recipient.role === 'signer' && roleDefinition.role !== 'signer' && signerCount <= 1} @click=${() => { this.updateRecipientRole(recipient.id, roleDefinition.role); this.closeRecipientContextMenu(); }}>${recipient.role === roleDefinition.role ? icon('check', 12) : html`<span style="width: 12px;"></span>`}<span>${roleDefinition.label}</span></button>`)}
|
.anchorY=${this.recipientContextMenu.y}
|
||||||
</div>`;
|
.title=${recipient.name}
|
||||||
|
.actions=${this.recipientContextMenuActions(recipient)}
|
||||||
|
@contextmenu-action=${(event: CustomEvent<ISdigContextMenuActionEventDetail>) => this.handleRecipientContextMenuAction(event, recipient)}
|
||||||
|
></sdig-contextmenu>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderStepper(): TemplateResult {
|
private renderStepper(): TemplateResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user