From bdd3f5535493b505a1407f9b5c12a486b05f2832 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 22 Dec 2025 14:17:37 +0000 Subject: [PATCH] feat(dees-mobile-gallery): add mobile gallery component with fullscreen viewer, swipe navigation, action buttons (download/share/delete), PDF support and demo --- changelog.md | 10 + package.json | 2 +- ts_web/00_commitinfo_data.ts | 2 +- .../dees-mobile-gallery.demo.ts | 213 ++++++ .../dees-mobile-gallery.ts | 650 ++++++++++++++++++ .../00group-ui/dees-mobile-gallery/index.ts | 1 + ts_web/elements/00group-ui/index.ts | 1 + 7 files changed, 877 insertions(+), 2 deletions(-) create mode 100644 ts_web/elements/00group-ui/dees-mobile-gallery/dees-mobile-gallery.demo.ts create mode 100644 ts_web/elements/00group-ui/dees-mobile-gallery/dees-mobile-gallery.ts create mode 100644 ts_web/elements/00group-ui/dees-mobile-gallery/index.ts diff --git a/changelog.md b/changelog.md index ce68ff7..bacf8f7 100644 --- a/changelog.md +++ b/changelog.md @@ -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 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 diff --git a/package.json b/package.json index 86ce423..c9f1dfc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 29e7ed6..c22c48d 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.1.0', + version: '1.3.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-gallery/dees-mobile-gallery.demo.ts b/ts_web/elements/00group-ui/dees-mobile-gallery/dees-mobile-gallery.demo.ts new file mode 100644 index 0000000..79ba62d --- /dev/null +++ b/ts_web/elements/00group-ui/dees-mobile-gallery/dees-mobile-gallery.demo.ts @@ -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` + + +
+

Basic Gallery

+
+ { + 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) +
+

Fullscreen gallery with swipe navigation on mobile, arrow keys on desktop.

+
+ +
+

Single Image

+
+ { + 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 +
+

Single image view without navigation controls.

+
+ +
+

With Delete Action

+
+ { + 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 +
+

Shows delete action button in red.

+
+ +
+

Start at Specific Index

+
+ { + 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 +
+

Opens gallery starting at the third image.

+
+ +
+

With PDF

+
+ { + 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 +
+

PDF files show a placeholder with filename.

+
+ +
+

Static Factory Method

+
+ { + 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() +
+

Uses the static factory method that returns a promise.

+
+ +
+

Minimal Mode

+
+ { + 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 +
+

No filename or action buttons - just images.

+
+ `; +}; diff --git a/ts_web/elements/00group-ui/dees-mobile-gallery/dees-mobile-gallery.ts b/ts_web/elements/00group-ui/dees-mobile-gallery/dees-mobile-gallery.ts new file mode 100644 index 0000000..d1e6b02 --- /dev/null +++ b/ts_web/elements/00group-ui/dees-mobile-gallery/dees-mobile-gallery.ts @@ -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; +} + +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` + + + + + + `; + } + + /** + * 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); + }); + } +} diff --git a/ts_web/elements/00group-ui/dees-mobile-gallery/index.ts b/ts_web/elements/00group-ui/dees-mobile-gallery/index.ts new file mode 100644 index 0000000..76efc7e --- /dev/null +++ b/ts_web/elements/00group-ui/dees-mobile-gallery/index.ts @@ -0,0 +1 @@ +export * from './dees-mobile-gallery.js'; diff --git a/ts_web/elements/00group-ui/index.ts b/ts_web/elements/00group-ui/index.ts index 0091544..4bacd69 100644 --- a/ts_web/elements/00group-ui/index.ts +++ b/ts_web/elements/00group-ui/index.ts @@ -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';