4 Commits
v1.0.2 ... main

13 changed files with 1472 additions and 2 deletions

View File

@@ -1,5 +1,23 @@
# Changelog # Changelog
## 2025-12-22 - 1.3.0 - feat(dees-mobile-gallery)
add mobile gallery component with fullscreen viewer, swipe navigation, action buttons (download/share/delete), PDF support and demo
- Adds new <dees-mobile-gallery> web component (ts_web/elements/00group-ui/dees-mobile-gallery) with ~650 lines of implementation.
- Component features: fullscreen viewing, touch swipe and keyboard navigation, thumbnails, startIndex, minimal mode, and PDF placeholder support.
- Action buttons and events: download, share, delete (configurable); emits 'close', 'delete', 'download', and 'share' events.
- Provides a static DeesMobileGallery.show(items, config) factory method that returns a Promise with the user action result.
- Includes demo (dees-mobile-gallery.demo.ts) with usage examples and exports the component from the 00group-ui index.
- Bumps package version from 1.1.0 to 1.2.0 in package.json.
## 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) ## 2025-12-22 - 1.0.2 - fix(dees-mobile-header)
adjust mobile header action slot layout and add documentation/license files adjust mobile header action slot layout and add documentation/license files

View File

@@ -1,6 +1,6 @@
{ {
"name": "@design.estate/dees-catalog-mobile", "name": "@design.estate/dees-catalog-mobile",
"version": "1.0.2", "version": "1.3.0",
"private": false, "private": false,
"description": "A mobile-optimized component catalog for building cross-platform business applications with touch-first UI components.", "description": "A mobile-optimized component catalog for building cross-platform business applications with touch-first UI components.",
"main": "dist_ts_web/index.js", "main": "dist_ts_web/index.js",

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@design.estate/dees-catalog-mobile', name: '@design.estate/dees-catalog-mobile',
version: '1.0.2', version: '1.3.0',
description: 'A mobile-optimized component catalog for building cross-platform business applications with touch-first UI components.' description: 'A mobile-optimized component catalog for building cross-platform business applications with touch-first UI components.'
} }

View File

@@ -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`
<style>
.demo-container {
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.demo-area {
padding: 3rem;
border: 2px dashed #e4e4e7;
border-radius: 0.5rem;
text-align: center;
cursor: context-menu;
user-select: none;
}
.demo-area:hover {
border-color: #a1a1aa;
background: #f4f4f5;
}
.demo-description {
font-size: 0.875rem;
color: #71717a;
text-align: center;
}
</style>
<div class="demo-container">
<div class="demo-description">
Right-click (or long-press on touch) to show context menu
</div>
<div
class="demo-area"
@contextmenu=${showContextMenu}
>
Right-click here
</div>
</div>
`;
};

View File

@@ -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`
<div class="menu">
${this.items.map((item) =>
item.divider
? html`<div class="divider"></div>`
: html`
<button
class="menu-item ${item.danger ? 'danger' : ''}"
@click=${() => this.handleItemClick(item)}
>
${item.icon
? html`<dees-mobile-icon icon="${item.icon}" size="16"></dees-mobile-icon>`
: ''}
${item.label || ''}
</button>
`
)}
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-contextmenu.js';

View File

@@ -0,0 +1,213 @@
import { html } from '@design.estate/dees-element';
import { injectCssVariables } from '../../00variables.js';
import type { DeesMobileGallery } from './dees-mobile-gallery.js';
export const demoFunc = () => {
injectCssVariables();
// Sample images for demo
const sampleImages = [
{
id: '1',
url: 'https://picsum.photos/800/1200?random=1',
thumbnailUrl: 'https://picsum.photos/100/100?random=1',
filename: 'receipt_001.jpg',
},
{
id: '2',
url: 'https://picsum.photos/1200/800?random=2',
thumbnailUrl: 'https://picsum.photos/100/100?random=2',
filename: 'shopping_photo.jpg',
},
{
id: '3',
url: 'https://picsum.photos/800/800?random=3',
thumbnailUrl: 'https://picsum.photos/100/100?random=3',
filename: 'receipt_002.jpg',
},
{
id: '4',
url: 'https://example.com/document.pdf',
filename: 'invoice.pdf',
mimeType: 'application/pdf',
},
];
return html`
<style>
.demo-section {
margin-bottom: 2rem;
}
.demo-section h3 {
margin: 0 0 1rem 0;
font-size: 0.875rem;
color: var(--dees-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.demo-note {
font-size: 0.875rem;
color: var(--dees-muted-foreground);
margin-top: 0.5rem;
}
.demo-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>
<div class="demo-section">
<h3>Basic Gallery</h3>
<div class="demo-buttons">
<dees-mobile-button
@click=${() => {
const gallery = document.createElement('dees-mobile-gallery') as DeesMobileGallery;
gallery.items = sampleImages.slice(0, 3);
gallery.config = {
showFilename: true,
showActions: true,
allowDownload: true,
allowDelete: false,
};
gallery.addEventListener('close', () => gallery.remove());
gallery.addEventListener('download', (e: CustomEvent) => {
console.log('Download requested:', e.detail);
});
document.body.appendChild(gallery);
}}
>Open Gallery (3 images)</dees-mobile-button>
</div>
<p class="demo-note">Fullscreen gallery with swipe navigation on mobile, arrow keys on desktop.</p>
</div>
<div class="demo-section">
<h3>Single Image</h3>
<div class="demo-buttons">
<dees-mobile-button
variant="outline"
@click=${() => {
const gallery = document.createElement('dees-mobile-gallery') as DeesMobileGallery;
gallery.items = [sampleImages[0]];
gallery.config = {
showFilename: true,
allowDownload: true,
};
gallery.addEventListener('close', () => gallery.remove());
document.body.appendChild(gallery);
}}
>View Single Image</dees-mobile-button>
</div>
<p class="demo-note">Single image view without navigation controls.</p>
</div>
<div class="demo-section">
<h3>With Delete Action</h3>
<div class="demo-buttons">
<dees-mobile-button
variant="outline"
@click=${() => {
const gallery = document.createElement('dees-mobile-gallery') as DeesMobileGallery;
gallery.items = sampleImages.slice(0, 3);
gallery.config = {
showFilename: true,
allowDownload: true,
allowDelete: true,
};
gallery.addEventListener('close', () => gallery.remove());
gallery.addEventListener('delete', (e: CustomEvent) => {
console.log('Delete requested:', e.detail);
gallery.remove();
});
document.body.appendChild(gallery);
}}
>Gallery with Delete</dees-mobile-button>
</div>
<p class="demo-note">Shows delete action button in red.</p>
</div>
<div class="demo-section">
<h3>Start at Specific Index</h3>
<div class="demo-buttons">
<dees-mobile-button
variant="outline"
@click=${() => {
const gallery = document.createElement('dees-mobile-gallery') as DeesMobileGallery;
gallery.items = sampleImages.slice(0, 3);
gallery.config = {
startIndex: 2,
showFilename: true,
allowDownload: true,
};
gallery.addEventListener('close', () => gallery.remove());
document.body.appendChild(gallery);
}}
>Open at Image 3</dees-mobile-button>
</div>
<p class="demo-note">Opens gallery starting at the third image.</p>
</div>
<div class="demo-section">
<h3>With PDF</h3>
<div class="demo-buttons">
<dees-mobile-button
variant="outline"
@click=${() => {
const gallery = document.createElement('dees-mobile-gallery') as DeesMobileGallery;
gallery.items = sampleImages;
gallery.config = {
startIndex: 3,
showFilename: true,
allowDownload: true,
};
gallery.addEventListener('close', () => gallery.remove());
document.body.appendChild(gallery);
}}
>Gallery with PDF</dees-mobile-button>
</div>
<p class="demo-note">PDF files show a placeholder with filename.</p>
</div>
<div class="demo-section">
<h3>Static Factory Method</h3>
<div class="demo-buttons">
<dees-mobile-button
variant="outline"
@click=${async () => {
const { DeesMobileGallery } = await import('./dees-mobile-gallery.js');
const result = await DeesMobileGallery.show(
sampleImages.slice(0, 3),
{
showFilename: true,
allowDownload: true,
allowDelete: true,
}
);
console.log('Gallery result:', result);
}}
>Use Static show()</dees-mobile-button>
</div>
<p class="demo-note">Uses the static factory method that returns a promise.</p>
</div>
<div class="demo-section">
<h3>Minimal Mode</h3>
<div class="demo-buttons">
<dees-mobile-button
variant="outline"
@click=${() => {
const gallery = document.createElement('dees-mobile-gallery') as DeesMobileGallery;
gallery.items = sampleImages.slice(0, 3);
gallery.config = {
showFilename: false,
showActions: false,
};
gallery.addEventListener('close', () => gallery.remove());
document.body.appendChild(gallery);
}}
>Minimal Gallery</dees-mobile-button>
</div>
<p class="demo-note">No filename or action buttons - just images.</p>
</div>
`;
};

View File

@@ -0,0 +1,650 @@
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-gallery.demo.js';
export interface IGalleryItem {
id: string;
url: string;
thumbnailUrl?: string;
filename?: string;
mimeType?: string;
metadata?: Record<string, any>;
}
export interface IGalleryConfig {
showFilename?: boolean;
showActions?: boolean;
allowDelete?: boolean;
allowDownload?: boolean;
allowShare?: boolean;
startIndex?: number;
}
declare global {
interface HTMLElementTagNameMap {
'dees-mobile-gallery': DeesMobileGallery;
}
}
@customElement('dees-mobile-gallery')
export class DeesMobileGallery extends DeesElement {
public static demo = demoFunc;
@property({ type: Array })
accessor items: IGalleryItem[] = [];
@property({ type: Object })
accessor config: IGalleryConfig = {};
@state()
accessor currentIndex: number = 0;
@state()
accessor isLoading: boolean = true;
@state()
accessor showThumbnails: boolean = false;
// Touch/swipe state
private touchStartX: number = 0;
private touchStartY: number = 0;
private touchDeltaX: number = 0;
private isSwiping: boolean = false;
private swipeThreshold: number = 50;
public static styles = [
cssManager.defaultStyles,
mobileComponentStyles,
css`
:host {
position: fixed;
inset: 0;
z-index: var(--dees-z-modal, 500);
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0);
animation: fadeInGallery 0.2s ease-out forwards;
}
@keyframes fadeInGallery {
to {
background: rgba(0, 0, 0, 0.95);
}
}
.gallery-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--dees-space-md);
padding-top: calc(var(--dees-space-md) + env(safe-area-inset-top, 0px));
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), transparent);
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: var(--dees-space-sm);
flex: 1;
min-width: 0;
}
.close-button {
width: 40px;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: var(--dees-radius-full);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--dees-transition-fast);
flex-shrink: 0;
}
.close-button:hover {
background: rgba(255, 255, 255, 0.2);
}
.filename {
color: white;
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.counter {
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
flex-shrink: 0;
}
.gallery-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
touch-action: pan-y pinch-zoom;
}
.image-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease-out;
}
.image-container.swiping {
transition: none;
}
.gallery-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
opacity: 0;
transition: opacity 0.2s ease-out;
}
.gallery-image.loaded {
opacity: 1;
}
.loading-spinner {
position: absolute;
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.nav-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: var(--dees-radius-full);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background var(--dees-transition-fast), opacity var(--dees-transition-fast);
z-index: 5;
opacity: 0.7;
}
.nav-button:hover {
background: rgba(255, 255, 255, 0.2);
opacity: 1;
}
.nav-button.prev {
left: var(--dees-space-md);
}
.nav-button.next {
right: var(--dees-space-md);
}
.nav-button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Hide nav buttons on mobile - use swipe instead */
@media (max-width: 640px) {
.nav-button {
display: none;
}
}
.gallery-footer {
padding: var(--dees-space-md);
padding-bottom: calc(var(--dees-space-md) + env(safe-area-inset-bottom, 0px));
background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
}
.actions {
display: flex;
justify-content: center;
gap: var(--dees-space-lg);
}
.action-button {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--dees-space-xs);
border: none;
background: none;
color: white;
cursor: pointer;
padding: var(--dees-space-sm);
border-radius: var(--dees-radius-md);
transition: background var(--dees-transition-fast);
min-width: 64px;
}
.action-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.action-button .icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
border-radius: var(--dees-radius-full);
}
.action-button .label {
font-size: 0.75rem;
opacity: 0.9;
}
.action-button.danger .icon {
background: rgba(220, 38, 38, 0.3);
}
.action-button.danger {
color: #fca5a5;
}
.thumbnails {
display: flex;
gap: var(--dees-space-xs);
justify-content: center;
margin-bottom: var(--dees-space-md);
overflow-x: auto;
padding: 0 var(--dees-space-md);
-webkit-overflow-scrolling: touch;
}
.thumbnail {
width: 48px;
height: 48px;
border-radius: var(--dees-radius-sm);
object-fit: cover;
cursor: pointer;
opacity: 0.5;
transition: opacity var(--dees-transition-fast), transform var(--dees-transition-fast);
flex-shrink: 0;
border: 2px solid transparent;
}
.thumbnail:hover {
opacity: 0.8;
}
.thumbnail.active {
opacity: 1;
border-color: white;
}
.pdf-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--dees-space-md);
color: white;
}
.pdf-icon {
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.1);
border-radius: var(--dees-radius-lg);
display: flex;
align-items: center;
justify-content: center;
}
.pdf-text {
font-size: 0.875rem;
opacity: 0.7;
}
`,
];
async connectedCallback() {
await super.connectedCallback();
this.currentIndex = this.config.startIndex ?? 0;
document.addEventListener('keydown', this.handleKeydown);
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('keydown', this.handleKeydown);
}
private handleKeydown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
this.handleClose();
break;
case 'ArrowLeft':
this.goToPrevious();
break;
case 'ArrowRight':
this.goToNext();
break;
}
};
private handleClose() {
this.dispatchEvent(new CustomEvent('close', {
bubbles: true,
composed: true,
}));
}
private handleTouchStart = (e: TouchEvent) => {
this.touchStartX = e.touches[0].clientX;
this.touchStartY = e.touches[0].clientY;
this.touchDeltaX = 0;
this.isSwiping = false;
};
private handleTouchMove = (e: TouchEvent) => {
const deltaX = e.touches[0].clientX - this.touchStartX;
const deltaY = e.touches[0].clientY - this.touchStartY;
// Only swipe horizontally if horizontal movement is greater than vertical
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
this.isSwiping = true;
this.touchDeltaX = deltaX;
this.requestUpdate();
}
};
private handleTouchEnd = () => {
if (this.isSwiping) {
if (this.touchDeltaX > this.swipeThreshold) {
this.goToPrevious();
} else if (this.touchDeltaX < -this.swipeThreshold) {
this.goToNext();
}
}
this.isSwiping = false;
this.touchDeltaX = 0;
this.requestUpdate();
};
private goToPrevious() {
if (this.currentIndex > 0) {
this.currentIndex--;
this.isLoading = true;
this.dispatchChangeEvent();
}
}
private goToNext() {
if (this.currentIndex < this.items.length - 1) {
this.currentIndex++;
this.isLoading = true;
this.dispatchChangeEvent();
}
}
private goToIndex(index: number) {
if (index >= 0 && index < this.items.length && index !== this.currentIndex) {
this.currentIndex = index;
this.isLoading = true;
this.dispatchChangeEvent();
}
}
private dispatchChangeEvent() {
this.dispatchEvent(new CustomEvent('change', {
detail: {
index: this.currentIndex,
item: this.items[this.currentIndex],
},
bubbles: true,
composed: true,
}));
}
private handleImageLoad() {
this.isLoading = false;
}
private handleDownload() {
const item = this.items[this.currentIndex];
this.dispatchEvent(new CustomEvent('download', {
detail: item,
bubbles: true,
composed: true,
}));
}
private handleDelete() {
const item = this.items[this.currentIndex];
this.dispatchEvent(new CustomEvent('delete', {
detail: item,
bubbles: true,
composed: true,
}));
}
private handleShare() {
const item = this.items[this.currentIndex];
this.dispatchEvent(new CustomEvent('share', {
detail: item,
bubbles: true,
composed: true,
}));
}
private isPdf(item: IGalleryItem): boolean {
return item.mimeType?.toLowerCase() === 'application/pdf' ||
item.filename?.toLowerCase().endsWith('.pdf') ||
item.url.toLowerCase().endsWith('.pdf');
}
public render(): TemplateResult {
const currentItem = this.items[this.currentIndex];
const showFilename = this.config.showFilename ?? true;
const showActions = this.config.showActions ?? true;
const allowDelete = this.config.allowDelete ?? false;
const allowDownload = this.config.allowDownload ?? true;
const allowShare = this.config.allowShare ?? false;
const showThumbnails = this.items.length > 1;
const swipeTransform = this.isSwiping ? `translateX(${this.touchDeltaX}px)` : '';
return html`
<div class="gallery-header">
<div class="header-left">
<button class="close-button" @click=${this.handleClose}>
<dees-mobile-icon icon="x" size="24"></dees-mobile-icon>
</button>
${showFilename && currentItem?.filename ? html`
<span class="filename">${currentItem.filename}</span>
` : ''}
</div>
${this.items.length > 1 ? html`
<span class="counter">${this.currentIndex + 1} / ${this.items.length}</span>
` : ''}
</div>
<div
class="gallery-content"
@touchstart=${this.handleTouchStart}
@touchmove=${this.handleTouchMove}
@touchend=${this.handleTouchEnd}
>
${this.isLoading ? html`<div class="loading-spinner"></div>` : ''}
<div
class="image-container ${this.isSwiping ? 'swiping' : ''}"
style=${swipeTransform ? `transform: ${swipeTransform}` : ''}
>
${currentItem ? (
this.isPdf(currentItem) ? html`
<div class="pdf-preview">
<div class="pdf-icon">
<dees-mobile-icon icon="file-text" size="48" color="white"></dees-mobile-icon>
</div>
<span class="pdf-text">${currentItem.filename || 'PDF Document'}</span>
</div>
` : html`
<img
class="gallery-image ${!this.isLoading ? 'loaded' : ''}"
src=${currentItem.url}
alt=${currentItem.filename || ''}
@load=${this.handleImageLoad}
/>
`
) : ''}
</div>
${this.items.length > 1 ? html`
<button
class="nav-button prev"
@click=${this.goToPrevious}
?disabled=${this.currentIndex === 0}
>
<dees-mobile-icon icon="chevron-left" size="28"></dees-mobile-icon>
</button>
<button
class="nav-button next"
@click=${this.goToNext}
?disabled=${this.currentIndex === this.items.length - 1}
>
<dees-mobile-icon icon="chevron-right" size="28"></dees-mobile-icon>
</button>
` : ''}
</div>
<div class="gallery-footer">
${showThumbnails ? html`
<div class="thumbnails">
${this.items.map((item, index) => html`
<img
class="thumbnail ${index === this.currentIndex ? 'active' : ''}"
src=${item.thumbnailUrl || item.url}
alt=""
@click=${() => this.goToIndex(index)}
/>
`)}
</div>
` : ''}
${showActions ? html`
<div class="actions">
${allowDownload ? html`
<button class="action-button" @click=${this.handleDownload}>
<div class="icon">
<dees-mobile-icon icon="download" size="22"></dees-mobile-icon>
</div>
<span class="label">Download</span>
</button>
` : ''}
${allowShare ? html`
<button class="action-button" @click=${this.handleShare}>
<div class="icon">
<dees-mobile-icon icon="share" size="22"></dees-mobile-icon>
</div>
<span class="label">Share</span>
</button>
` : ''}
${allowDelete ? html`
<button class="action-button danger" @click=${this.handleDelete}>
<div class="icon">
<dees-mobile-icon icon="trash-2" size="22"></dees-mobile-icon>
</div>
<span class="label">Delete</span>
</button>
` : ''}
</div>
` : ''}
</div>
`;
}
/**
* Static factory method to show the gallery
*/
public static async show(
items: IGalleryItem[],
config: IGalleryConfig = {}
): Promise<{ action: 'close' | 'delete' | 'download' | 'share'; item?: IGalleryItem }> {
return new Promise((resolve) => {
const gallery = document.createElement('dees-mobile-gallery') as DeesMobileGallery;
gallery.items = items;
gallery.config = config;
const cleanup = () => {
gallery.remove();
};
gallery.addEventListener('close', () => {
cleanup();
resolve({ action: 'close' });
});
gallery.addEventListener('delete', (e: CustomEvent) => {
cleanup();
resolve({ action: 'delete', item: e.detail });
});
gallery.addEventListener('download', (e: CustomEvent) => {
// Don't close on download - user might want to download multiple
resolve({ action: 'download', item: e.detail });
});
gallery.addEventListener('share', (e: CustomEvent) => {
resolve({ action: 'share', item: e.detail });
});
document.body.appendChild(gallery);
});
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-gallery.js';

View File

@@ -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`
<style>
.demo-container {
padding: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.demo-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.demo-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #71717a;
}
.demo-row {
display: flex;
align-items: center;
gap: 1rem;
}
</style>
<div class="demo-container">
<div class="demo-section">
<div class="demo-label">Sizes</div>
<div class="demo-row">
<dees-mobile-iconbutton size="sm" label="Small">
<dees-mobile-icon icon="settings" size="16"></dees-mobile-icon>
</dees-mobile-iconbutton>
<dees-mobile-iconbutton size="md" label="Medium">
<dees-mobile-icon icon="settings" size="20"></dees-mobile-icon>
</dees-mobile-iconbutton>
<dees-mobile-iconbutton size="lg" label="Large">
<dees-mobile-icon icon="settings" size="24"></dees-mobile-icon>
</dees-mobile-iconbutton>
</div>
</div>
<div class="demo-section">
<div class="demo-label">Common Actions</div>
<div class="demo-row">
<dees-mobile-iconbutton label="Edit">
<dees-mobile-icon icon="pencil" size="20"></dees-mobile-icon>
</dees-mobile-iconbutton>
<dees-mobile-iconbutton label="Delete">
<dees-mobile-icon icon="trash-2" size="20"></dees-mobile-icon>
</dees-mobile-iconbutton>
<dees-mobile-iconbutton label="Share">
<dees-mobile-icon icon="share" size="20"></dees-mobile-icon>
</dees-mobile-iconbutton>
<dees-mobile-iconbutton label="More">
<dees-mobile-icon icon="more-vertical" size="20"></dees-mobile-icon>
</dees-mobile-iconbutton>
</div>
</div>
<div class="demo-section">
<div class="demo-label">States</div>
<div class="demo-row">
<dees-mobile-iconbutton label="Normal">
<dees-mobile-icon icon="check" size="20"></dees-mobile-icon>
</dees-mobile-iconbutton>
<dees-mobile-iconbutton label="Disabled" disabled>
<dees-mobile-icon icon="check" size="20"></dees-mobile-icon>
</dees-mobile-iconbutton>
</div>
</div>
</div>
`;
};

View File

@@ -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`
<button
class=${this.size}
?disabled=${this.disabled}
aria-label=${this.label}
title=${this.label}
>
<slot></slot>
</button>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-iconbutton.js';

View File

@@ -1,6 +1,9 @@
// Core UI Components // Core UI Components
export * from './dees-mobile-button/index.js'; export * from './dees-mobile-button/index.js';
export * from './dees-mobile-contextmenu/index.js';
export * from './dees-mobile-gallery/index.js';
export * from './dees-mobile-icon/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-header/index.js';
export * from './dees-mobile-modal/index.js'; export * from './dees-mobile-modal/index.js';
export * from './dees-mobile-actionsheet/index.js'; export * from './dees-mobile-actionsheet/index.js';