feat(dees-mobile-gallery): add mobile gallery component with fullscreen viewer, swipe navigation, action buttons (download/share/delete), PDF support and demo

This commit is contained in:
2025-12-22 14:17:37 +00:00
parent d0227fd5a6
commit bdd3f55354
7 changed files with 877 additions and 2 deletions

View File

@@ -1,5 +1,15 @@
# 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

View File

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

View File

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

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

@@ -1,6 +1,7 @@
// Core UI Components
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-iconbutton/index.js';
export * from './dees-mobile-header/index.js';