WIP PWA for Grain
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: alt text overlay stays attached on page scroll

Move overlay rendering from grain-alt-badge to grain-image-carousel.
The overlay now uses position:absolute within the slide instead of
position:fixed with JS positioning, so it naturally scrolls with content.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+48 -87
+6 -85
src/components/atoms/grain-alt-badge.js
··· 2 2 3 3 export class GrainAltBadge extends LitElement { 4 4 static properties = { 5 - alt: { type: String }, 6 - _showOverlay: { state: true } 5 + alt: { type: String } 7 6 }; 8 7 9 8 static styles = css` ··· 32 31 outline: 2px solid white; 33 32 outline-offset: 1px; 34 33 } 35 - .overlay { 36 - position: absolute; 37 - bottom: 8px; 38 - right: 8px; 39 - left: -8px; 40 - top: -8px; 41 - transform: translate(-100%, -100%); 42 - transform: none; 43 - bottom: 0; 44 - right: 0; 45 - left: 0; 46 - top: 0; 47 - position: fixed; 48 - background: rgba(0, 0, 0, 0.75); 49 - color: white; 50 - padding: 16px; 51 - font-size: 14px; 52 - line-height: 1.5; 53 - overflow-y: auto; 54 - display: flex; 55 - align-items: center; 56 - justify-content: center; 57 - text-align: center; 58 - box-sizing: border-box; 59 - } 60 34 `; 61 35 62 - #scrollHandler = null; 63 - #carousel = null; 64 - 65 36 constructor() { 66 37 super(); 67 38 this.alt = ''; 68 - this._showOverlay = false; 69 - } 70 - 71 - disconnectedCallback() { 72 - super.disconnectedCallback(); 73 - this.#removeScrollListener(); 74 39 } 75 40 76 41 #handleClick(e) { 77 42 e.stopPropagation(); 78 - this._showOverlay = !this._showOverlay; 79 - } 80 - 81 - #handleOverlayClick(e) { 82 - e.stopPropagation(); 83 - this._showOverlay = false; 84 - } 85 - 86 - #removeScrollListener() { 87 - if (this.#scrollHandler && this.#carousel) { 88 - this.#carousel.removeEventListener('scroll', this.#scrollHandler); 89 - this.#scrollHandler = null; 90 - this.#carousel = null; 91 - } 92 - } 93 - 94 - updated(changedProperties) { 95 - if (changedProperties.has('_showOverlay')) { 96 - if (this._showOverlay) { 97 - // Position overlay to cover the parent slide 98 - const slide = this.closest('.slide'); 99 - if (slide) { 100 - const rect = slide.getBoundingClientRect(); 101 - const overlay = this.shadowRoot.querySelector('.overlay'); 102 - if (overlay) { 103 - overlay.style.top = `${rect.top}px`; 104 - overlay.style.left = `${rect.left}px`; 105 - overlay.style.width = `${rect.width}px`; 106 - overlay.style.height = `${rect.height}px`; 107 - } 108 - 109 - // Listen for carousel scroll to dismiss overlay 110 - this.#carousel = slide.parentElement; 111 - if (this.#carousel) { 112 - this.#scrollHandler = () => { 113 - this._showOverlay = false; 114 - }; 115 - this.#carousel.addEventListener('scroll', this.#scrollHandler, { passive: true }); 116 - } 117 - } 118 - } else { 119 - this.#removeScrollListener(); 120 - } 121 - } 43 + this.dispatchEvent(new CustomEvent('alt-click', { 44 + bubbles: true, 45 + composed: true, 46 + detail: { alt: this.alt } 47 + })); 122 48 } 123 49 124 50 render() { ··· 126 52 127 53 return html` 128 54 <button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button> 129 - ${this._showOverlay ? html` 130 - <div class="overlay" @click=${this.#handleOverlayClick}> 131 - ${this.alt} 132 - </div> 133 - ` : ''} 134 55 `; 135 56 } 136 57 }
+42 -2
src/components/organisms/grain-image-carousel.js
··· 8 8 static properties = { 9 9 photos: { type: Array }, 10 10 rkey: { type: String }, 11 - _currentIndex: { state: true } 11 + _currentIndex: { state: true }, 12 + _activeAltIndex: { state: true } 12 13 }; 13 14 14 15 static styles = css` ··· 78 79 .nav-arrow-right { 79 80 right: 8px; 80 81 } 82 + .alt-overlay { 83 + position: absolute; 84 + inset: 0; 85 + background: rgba(0, 0, 0, 0.75); 86 + color: white; 87 + padding: 16px; 88 + font-size: 14px; 89 + line-height: 1.5; 90 + overflow-y: auto; 91 + display: flex; 92 + align-items: center; 93 + justify-content: center; 94 + text-align: center; 95 + box-sizing: border-box; 96 + z-index: 3; 97 + cursor: pointer; 98 + } 81 99 `; 82 100 83 101 constructor() { 84 102 super(); 85 103 this.photos = []; 86 104 this._currentIndex = 0; 105 + this._activeAltIndex = null; 87 106 } 88 107 89 108 get #hasPortrait() { ··· 100 119 const index = Math.round(carousel.scrollLeft / carousel.offsetWidth); 101 120 if (index !== this._currentIndex) { 102 121 this._currentIndex = index; 122 + this._activeAltIndex = null; 103 123 } 104 124 } 105 125 126 + #handleAltClick(e, index) { 127 + e.stopPropagation(); 128 + this._activeAltIndex = index; 129 + } 130 + 131 + #handleOverlayClick(e) { 132 + e.stopPropagation(); 133 + this._activeAltIndex = null; 134 + } 135 + 106 136 #goToPrevious(e) { 107 137 e.stopPropagation(); 108 138 if (this._currentIndex > 0) { ··· 158 188 aspectRatio=${photo.aspectRatio || 1} 159 189 style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 160 190 ></grain-image> 161 - ${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''} 191 + ${photo.alt ? html` 192 + <grain-alt-badge 193 + .alt=${photo.alt} 194 + @alt-click=${(e) => this.#handleAltClick(e, index)} 195 + ></grain-alt-badge> 196 + ` : ''} 197 + ${this._activeAltIndex === index ? html` 198 + <div class="alt-overlay" @click=${this.#handleOverlayClick}> 199 + ${photo.alt} 200 + </div> 201 + ` : ''} 162 202 </div> 163 203 `)} 164 204 </div>