Files

235 lines
7.3 KiB
TypeScript

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>
`;
}
}