diff --git a/changelog.md b/changelog.md index 7795517..ce68ff7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-12-22 - 1.1.0 - feat(ui) +add mobile context menu and iconbutton components with demos and exports + +- Added dees-mobile-contextmenu component (items API, viewport-aware positioning, touch handling, open/close animation) and a demo +- Added dees-mobile-iconbutton component (sm/md/lg sizes, disabled state, accessibility attributes) and a demo +- Added barrel index files for both components +- Exported the new components from ts_web/elements/00group-ui/index.ts + ## 2025-12-22 - 1.0.2 - fix(dees-mobile-header) adjust mobile header action slot layout and add documentation/license files diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 570e68d..29e7ed6 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog-mobile', - version: '1.0.2', + version: '1.1.0', description: 'A mobile-optimized component catalog for building cross-platform business applications with touch-first UI components.' } diff --git a/ts_web/elements/00group-ui/dees-mobile-contextmenu/dees-mobile-contextmenu.demo.ts b/ts_web/elements/00group-ui/dees-mobile-contextmenu/dees-mobile-contextmenu.demo.ts new file mode 100644 index 0000000..4f70ca9 --- /dev/null +++ b/ts_web/elements/00group-ui/dees-mobile-contextmenu/dees-mobile-contextmenu.demo.ts @@ -0,0 +1,82 @@ +import { html, type TemplateResult } from '@design.estate/dees-element'; +import type { IContextMenuItem } from './dees-mobile-contextmenu.js'; + +export const demoFunc = (): TemplateResult => { + const showContextMenu = (e: MouseEvent) => { + e.preventDefault(); + + const items: IContextMenuItem[] = [ + { + label: 'Edit', + icon: 'pencil', + action: () => console.log('Edit clicked'), + }, + { + label: 'Duplicate', + icon: 'copy', + action: () => console.log('Duplicate clicked'), + }, + { divider: true }, + { + label: 'Share', + icon: 'share', + action: () => console.log('Share clicked'), + }, + { divider: true }, + { + label: 'Delete', + icon: 'trash-2', + danger: true, + action: () => console.log('Delete clicked'), + }, + ]; + + import('./dees-mobile-contextmenu.js').then(({ DeesMobileContextmenu }) => { + DeesMobileContextmenu.createAndShow(items, e.clientX, e.clientY); + }); + }; + + return html` + + +
+
+ Right-click (or long-press on touch) to show context menu +
+ +
+ Right-click here +
+
+ `; +}; diff --git a/ts_web/elements/00group-ui/dees-mobile-contextmenu/dees-mobile-contextmenu.ts b/ts_web/elements/00group-ui/dees-mobile-contextmenu/dees-mobile-contextmenu.ts new file mode 100644 index 0000000..2af11fb --- /dev/null +++ b/ts_web/elements/00group-ui/dees-mobile-contextmenu/dees-mobile-contextmenu.ts @@ -0,0 +1,283 @@ +import { + DeesElement, + css, + cssManager, + customElement, + html, + property, + state, + type TemplateResult, +} from '@design.estate/dees-element'; + +import { mobileComponentStyles } from '../../00componentstyles.js'; +import '../dees-mobile-icon/dees-mobile-icon.js'; +import { demoFunc } from './dees-mobile-contextmenu.demo.js'; + +export interface IContextMenuItem { + label?: string; + icon?: string; + action?: () => void; + danger?: boolean; + divider?: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'dees-mobile-contextmenu': DeesMobileContextmenu; + } +} + +@customElement('dees-mobile-contextmenu') +export class DeesMobileContextmenu extends DeesElement { + public static demo = demoFunc; + + @property({ type: Array }) + accessor items: IContextMenuItem[] = []; + + @property({ type: Number }) + accessor x: number = 0; + + @property({ type: Number }) + accessor y: number = 0; + + @property({ type: Boolean }) + accessor isTouch: boolean = false; + + @state() + accessor isClosing: boolean = false; + + @state() + accessor transformOrigin: string = 'top left'; + + public static styles = [ + cssManager.defaultStyles, + mobileComponentStyles, + css` + :host { + position: fixed; + z-index: var(--dees-z-contextmenu, 10000); + } + + :host(.closing) .menu { + animation: scaleOut 100ms ease-in; + } + + .menu { + background: ${cssManager.bdTheme('#ffffff', '#18181b')}; + border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')}; + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + padding: 0.5rem 0; + min-width: 180px; + animation: scaleIn 100ms ease-out; + } + + .menu-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + color: ${cssManager.bdTheme('#09090b', '#fafafa')}; + cursor: pointer; + transition: all 100ms ease; + background: none; + border: none; + width: 100%; + text-align: left; + font-family: inherit; + } + + .menu-item:hover { + background: ${cssManager.bdTheme('#f4f4f5', '#27272a')}; + } + + .menu-item.danger { + color: ${cssManager.bdTheme('#dc2626', '#ef4444')}; + } + + .divider { + height: 1px; + background: ${cssManager.bdTheme('#e4e4e7', '#27272a')}; + margin: 0.25rem 0; + } + + @keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } + } + + @keyframes scaleOut { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.9); + } + } + `, + ]; + + /** + * Factory method to create and show a context menu + */ + public static createAndShow( + items: IContextMenuItem[], + x: number, + y: number, + isTouch = false + ): DeesMobileContextmenu { + // Remove any existing context menu + const existing = document.querySelector('dees-mobile-contextmenu'); + if (existing) { + existing.remove(); + } + + // Create new menu + const menu = document.createElement('dees-mobile-contextmenu') as DeesMobileContextmenu; + menu.items = items; + menu.x = x; + menu.y = y; + menu.isTouch = isTouch; + + // Add to document + document.body.appendChild(menu); + + // Position after render to handle viewport bounds + requestAnimationFrame(() => { + menu.adjustPosition(); + }); + + // Close on outside click + const handleClick = (e: MouseEvent) => { + if (!e.composedPath().includes(menu)) { + menu.close(); + document.removeEventListener('click', handleClick, true); + } + }; + + // Add listener on next tick to avoid immediate close + setTimeout(() => { + document.addEventListener('click', handleClick, true); + }, 0); + + return menu; + } + + private adjustPosition(): void { + const rect = this.getBoundingClientRect(); + const menuWidth = rect.width; + const menuHeight = rect.height; + const padding = 10; + + let adjustedX = this.x; + let adjustedY = this.y; + + // Calculate available space in each direction + const spaceTop = this.y - padding; + const spaceBottom = window.innerHeight - this.y - padding; + const spaceLeft = this.x - padding; + const spaceRight = window.innerWidth - this.x - padding; + + // For touch interactions, prefer opening upward if there's space + if (this.isTouch && spaceTop >= menuHeight) { + // Open upward from touch point + adjustedY = this.y - menuHeight; + this.transformOrigin = 'bottom left'; + + // Adjust X if needed + if (spaceRight < menuWidth && spaceLeft >= menuWidth) { + adjustedX = this.x - menuWidth; + this.transformOrigin = 'bottom right'; + } + } else { + // Default behavior (open downward/rightward) + // Flip horizontally if not enough space on right + if (spaceRight < menuWidth && spaceLeft >= menuWidth) { + adjustedX = this.x - menuWidth; + this.transformOrigin = this.transformOrigin.replace('left', 'right'); + } + + // Flip vertically if not enough space below + if (spaceBottom < menuHeight && spaceTop >= menuHeight) { + adjustedY = this.y - menuHeight; + this.transformOrigin = this.transformOrigin.replace('top', 'bottom'); + } + } + + // Final boundary checks to keep menu fully visible + adjustedX = Math.max(padding, Math.min(adjustedX, window.innerWidth - menuWidth - padding)); + adjustedY = Math.max(padding, Math.min(adjustedY, window.innerHeight - menuHeight - padding)); + + this.style.left = `${adjustedX}px`; + this.style.top = `${adjustedY}px`; + + // Update the menu's transform origin + const menu = this.shadowRoot?.querySelector('.menu') as HTMLElement; + if (menu) { + menu.style.transformOrigin = this.transformOrigin; + } + } + + public close(): void { + if (this.isClosing) return; + + this.isClosing = true; + this.classList.add('closing'); + + // Wait for the next frame to ensure animation starts + requestAnimationFrame(() => { + // Listen for animation end + const menu = this.shadowRoot?.querySelector('.menu'); + if (menu) { + menu.addEventListener( + 'animationend', + () => { + this.remove(); + }, + { once: true } + ); + } else { + // Fallback if menu not found + setTimeout(() => this.remove(), 100); + } + }); + } + + private handleItemClick(item: IContextMenuItem): void { + if (!item.divider && item.action) { + item.action(); + this.close(); + } + } + + public render(): TemplateResult { + return html` + + `; + } +} diff --git a/ts_web/elements/00group-ui/dees-mobile-contextmenu/index.ts b/ts_web/elements/00group-ui/dees-mobile-contextmenu/index.ts new file mode 100644 index 0000000..6b6decf --- /dev/null +++ b/ts_web/elements/00group-ui/dees-mobile-contextmenu/index.ts @@ -0,0 +1 @@ +export * from './dees-mobile-contextmenu.js'; diff --git a/ts_web/elements/00group-ui/dees-mobile-iconbutton/dees-mobile-iconbutton.demo.ts b/ts_web/elements/00group-ui/dees-mobile-iconbutton/dees-mobile-iconbutton.demo.ts new file mode 100644 index 0000000..4225577 --- /dev/null +++ b/ts_web/elements/00group-ui/dees-mobile-iconbutton/dees-mobile-iconbutton.demo.ts @@ -0,0 +1,89 @@ +import { html, type TemplateResult } from '@design.estate/dees-element'; +import './dees-mobile-iconbutton.js'; +import '../dees-mobile-icon/dees-mobile-icon.js'; + +export const demoFunc = (): TemplateResult => { + return html` + + +
+
+
Sizes
+
+ + + + + + + + + + + +
+
+ +
+
Common Actions
+
+ + + + + + + + + + + + + + + +
+
+ +
+
States
+
+ + + + + + + +
+
+
+ `; +}; diff --git a/ts_web/elements/00group-ui/dees-mobile-iconbutton/dees-mobile-iconbutton.ts b/ts_web/elements/00group-ui/dees-mobile-iconbutton/dees-mobile-iconbutton.ts new file mode 100644 index 0000000..611e5c9 --- /dev/null +++ b/ts_web/elements/00group-ui/dees-mobile-iconbutton/dees-mobile-iconbutton.ts @@ -0,0 +1,129 @@ +import { + DeesElement, + css, + cssManager, + customElement, + html, + property, + type TemplateResult, +} from '@design.estate/dees-element'; + +import { mobileComponentStyles } from '../../00componentstyles.js'; +import { demoFunc } from './dees-mobile-iconbutton.demo.js'; + +declare global { + interface HTMLElementTagNameMap { + 'dees-mobile-iconbutton': DeesMobileIconbutton; + } +} + +@customElement('dees-mobile-iconbutton') +export class DeesMobileIconbutton extends DeesElement { + public static demo = demoFunc; + + @property({ type: String }) + accessor label: string = ''; + + @property({ type: Boolean }) + accessor disabled: boolean = false; + + @property({ type: String }) + accessor size: 'sm' | 'md' | 'lg' = 'md'; + + public static styles = [ + cssManager.defaultStyles, + mobileComponentStyles, + css` + :host { + display: inline-block; + } + + button { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 0.375rem; + color: ${cssManager.bdTheme('#09090b', '#fafafa')}; + cursor: pointer; + transition: all 150ms ease; + transform: scale(1); + position: relative; + -webkit-tap-highlight-color: transparent; + width: 100%; + height: 100%; + } + + button:hover:not(:disabled) { + background-color: ${cssManager.bdTheme('#f4f4f5', '#27272a')}; + transform: scale(1.1); + } + + button:active:not(:disabled) { + transform: scale(0.95); + } + + button:focus-visible { + outline: 2px solid ${cssManager.bdTheme('#3b82f6', '#60a5fa')}; + outline-offset: 2px; + } + + button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Sizes */ + button.sm { + width: 2rem; + height: 2rem; + } + + button.md { + width: 2.5rem; + height: 2.5rem; + } + + button.lg { + width: 3rem; + height: 3rem; + } + + ::slotted(svg), + ::slotted(div), + ::slotted(dees-mobile-icon) { + width: 1.25rem; + height: 1.25rem; + pointer-events: none; + } + + button.sm ::slotted(svg), + button.sm ::slotted(div), + button.sm ::slotted(dees-mobile-icon) { + width: 1rem; + height: 1rem; + } + + button.lg ::slotted(svg), + button.lg ::slotted(div), + button.lg ::slotted(dees-mobile-icon) { + width: 1.5rem; + height: 1.5rem; + } + `, + ]; + + public render(): TemplateResult { + return html` + + `; + } +} diff --git a/ts_web/elements/00group-ui/dees-mobile-iconbutton/index.ts b/ts_web/elements/00group-ui/dees-mobile-iconbutton/index.ts new file mode 100644 index 0000000..ebee619 --- /dev/null +++ b/ts_web/elements/00group-ui/dees-mobile-iconbutton/index.ts @@ -0,0 +1 @@ +export * from './dees-mobile-iconbutton.js'; diff --git a/ts_web/elements/00group-ui/index.ts b/ts_web/elements/00group-ui/index.ts index 046f290..0091544 100644 --- a/ts_web/elements/00group-ui/index.ts +++ b/ts_web/elements/00group-ui/index.ts @@ -1,6 +1,8 @@ // Core UI Components export * from './dees-mobile-button/index.js'; +export * from './dees-mobile-contextmenu/index.js'; export * from './dees-mobile-icon/index.js'; +export * from './dees-mobile-iconbutton/index.js'; export * from './dees-mobile-header/index.js'; export * from './dees-mobile-modal/index.js'; export * from './dees-mobile-actionsheet/index.js';