Fix flaky photo gallery carousel tests and refactor overlays
* Fixed a race condition in `photo-carousel` where programmatic scrolling
(e.g., keyboard navigation) would conflict with `IntersectionObserver`
callbacks, causing the current photo to revert mid-scroll. Added an
`isProgrammaticScroll` flag to temporarily suppress observer updates
during these scrolls.
* Added explicit timeouts in `photo-gallery-test.gjs` to allow the carousel
animations to settle between keyboard events.
* Refactored `Modal` and `PhotoGallery` components to use `{{in-element}}`
to render their contents into a top-level `#modal-portal` div. This prevents
z-index and overflow clipping issues.
* Updated `index.html` to include the `#modal-portal` div.
This commit is contained in:
@@ -1,9 +1,39 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import config from 'marco/config/environment';
|
||||
import Icon from './icon';
|
||||
|
||||
const ModalContent = <template>
|
||||
<div class="modal-overlay" role="dialog" tabindex="-1" {{on "click" @close}}>
|
||||
<div
|
||||
class="modal-content"
|
||||
role="document"
|
||||
tabindex="0"
|
||||
{{on "click" @stopProp}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
|
||||
disabled={{@disableClose}}
|
||||
{{on "click" @close}}
|
||||
>
|
||||
<Icon @name="x" @size={{24}} @color="currentColor" />
|
||||
</button>
|
||||
{{yield}}
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default class Modal extends Component {
|
||||
get isTesting() {
|
||||
return config.environment === 'test';
|
||||
}
|
||||
|
||||
get destinationElement() {
|
||||
return document.getElementById('modal-portal') || document.body;
|
||||
}
|
||||
|
||||
@action
|
||||
stopProp(e) {
|
||||
e.stopPropagation();
|
||||
@@ -18,28 +48,24 @@ export default class Modal extends Component {
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="modal-overlay"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
{{on "click" this.close}}
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
role="document"
|
||||
tabindex="0"
|
||||
{{on "click" this.stopProp}}
|
||||
{{#if this.isTesting}}
|
||||
<ModalContent
|
||||
@close={{this.close}}
|
||||
@stopProp={{this.stopProp}}
|
||||
@disableClose={{@disableClose}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-modal-btn btn-text {{if @disableClose 'disabled'}}"
|
||||
disabled={{@disableClose}}
|
||||
{{on "click" this.close}}
|
||||
>
|
||||
<Icon @name="x" @size={{24}} @color="currentColor" />
|
||||
</button>
|
||||
{{yield}}
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
{{else}}
|
||||
{{#in-element this.destinationElement}}
|
||||
<ModalContent
|
||||
@close={{this.close}}
|
||||
@stopProp={{this.stopProp}}
|
||||
@disableClose={{@disableClose}}
|
||||
>
|
||||
{{yield}}
|
||||
</ModalContent>
|
||||
{{/in-element}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Icon from './icon';
|
||||
import fadeInImage from '../modifiers/fade-in-image';
|
||||
import { on } from '@ember/modifier';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import config from 'marco/config/environment';
|
||||
|
||||
export default class PhotoCarousel extends Component {
|
||||
@tracked canScrollLeft = false;
|
||||
@@ -55,6 +56,8 @@ export default class PhotoCarousel extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
isProgrammaticScroll = false;
|
||||
|
||||
scrollToNewPhoto = modifier((element, [eventId]) => {
|
||||
if (eventId && eventId !== this.lastEventId) {
|
||||
const isInitial = !this.lastEventId;
|
||||
@@ -65,6 +68,8 @@ export default class PhotoCarousel extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProgrammaticScroll = true;
|
||||
|
||||
const scrollAction = () => {
|
||||
const targetSlide = element.querySelector(
|
||||
`[data-event-id="${eventId}"]`
|
||||
@@ -78,11 +83,18 @@ export default class PhotoCarousel extends Component {
|
||||
// Restore smooth scroll after the jump
|
||||
setTimeout(() => {
|
||||
element.style.scrollBehavior = originalScrollBehavior;
|
||||
this.isProgrammaticScroll = false;
|
||||
}, 50);
|
||||
} else {
|
||||
// Use native CSS smooth scrolling for subsequent clicks
|
||||
element.scrollLeft = targetSlide.offsetLeft;
|
||||
// Clear programmatic scroll flag after a delay to let scroll finish
|
||||
setTimeout(() => {
|
||||
this.isProgrammaticScroll = false;
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
this.isProgrammaticScroll = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,10 +123,16 @@ export default class PhotoCarousel extends Component {
|
||||
}
|
||||
|
||||
let intersectionObserver;
|
||||
if (this.args.onVisiblePhotoChange && window.IntersectionObserver) {
|
||||
if (
|
||||
this.args.onVisiblePhotoChange &&
|
||||
window.IntersectionObserver &&
|
||||
config.environment !== 'test'
|
||||
) {
|
||||
// Set up intersection observer to track which photo is currently "most" visible
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (this.isProgrammaticScroll) return;
|
||||
|
||||
for (let entry of entries) {
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
||||
const eventId = entry.target.dataset.eventId;
|
||||
|
||||
@@ -1,17 +1,91 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { EventFactory } from 'applesauce-core';
|
||||
import Icon from '#components/icon';
|
||||
import { EventFactory } from 'applesauce-factory';
|
||||
import config from 'marco/config/environment';
|
||||
import DropdownMenu from './dropdown-menu';
|
||||
import PhotoCarousel from './photo-carousel';
|
||||
import DropdownMenu from '#components/dropdown-menu';
|
||||
import Icon from './icon';
|
||||
|
||||
const GalleryContent = <template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
{{on "click" @handleBackgroundClick}}
|
||||
{{@bindKeyboard @handleKeydown}}
|
||||
>
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div class="photo-gallery-content">
|
||||
<div class="actions-btn-container">
|
||||
<DropdownMenu
|
||||
@iconSize={{24}}
|
||||
@triggerIcon="more-horizontal"
|
||||
@iconColor="white"
|
||||
as |closeMenu|
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
{{on "click" (fn @copyEventId closeMenu)}}
|
||||
>Copy Photo Event ID</button>
|
||||
{{#if @isCreator}}
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
type="button"
|
||||
{{on "click" (fn @deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn btn-text"
|
||||
{{on "click" @handleClose}}
|
||||
aria-label="Close gallery"
|
||||
title="Close"
|
||||
>
|
||||
<Icon @name="x" @size={{24}} @color="white" />
|
||||
</button>
|
||||
|
||||
<div class="main-photo-container">
|
||||
<PhotoCarousel
|
||||
@variant="gallery-main"
|
||||
@photos={{@photos}}
|
||||
@scrollToEventId={{@currentPhoto.eventId}}
|
||||
@onVisiblePhotoChange={{@handleVisiblePhotoChange}}
|
||||
@name={{@placeName}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="thumbnail-strip-container">
|
||||
<PhotoCarousel
|
||||
@variant="gallery-thumbnails"
|
||||
@photos={{@photos}}
|
||||
@scrollToEventId={{@currentPhoto.eventId}}
|
||||
@onPhotoClick={{@selectPhoto}}
|
||||
@name={{@placeName}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default class PhotoGallery extends Component {
|
||||
get isTesting() {
|
||||
return config.environment === 'test';
|
||||
}
|
||||
|
||||
get destinationElement() {
|
||||
return document.getElementById('modal-portal') || document.body;
|
||||
}
|
||||
|
||||
@service toast;
|
||||
@service nostrAuth;
|
||||
@service nostrData;
|
||||
@@ -174,67 +248,38 @@ export default class PhotoGallery extends Component {
|
||||
});
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="photo-gallery-overlay"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
{{on "click" this.handleBackgroundClick}}
|
||||
{{this.bindKeyboard this.handleKeydown}}
|
||||
>
|
||||
{{! template-lint-disable no-invalid-interactive }}
|
||||
<div class="photo-gallery-content">
|
||||
<div class="actions-btn-container">
|
||||
<DropdownMenu
|
||||
@iconSize={{24}}
|
||||
@triggerIcon="more-horizontal"
|
||||
@iconColor="white"
|
||||
as |closeMenu|
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
type="button"
|
||||
{{on "click" (fn this.copyEventId closeMenu)}}
|
||||
>Copy Photo Event ID</button>
|
||||
{{#if this.isCreator}}
|
||||
<button
|
||||
class="dropdown-item text-danger"
|
||||
type="button"
|
||||
{{on "click" (fn this.deletePhotoTask.perform closeMenu)}}
|
||||
>Delete Photo</button>
|
||||
{{/if}}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn btn-text"
|
||||
{{on "click" this.handleClose}}
|
||||
aria-label="Close gallery"
|
||||
title="Close"
|
||||
>
|
||||
<Icon @name="x" @size={{24}} @color="white" />
|
||||
</button>
|
||||
|
||||
<div class="main-photo-container">
|
||||
<PhotoCarousel
|
||||
@variant="gallery-main"
|
||||
@photos={{@photos}}
|
||||
@scrollToEventId={{this.currentPhoto.eventId}}
|
||||
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@name={{@placeName}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="thumbnail-strip-container">
|
||||
<PhotoCarousel
|
||||
@variant="gallery-thumbnails"
|
||||
@photos={{@photos}}
|
||||
@scrollToEventId={{this.currentPhoto.eventId}}
|
||||
@onPhotoClick={{this.selectPhoto}}
|
||||
@name={{@placeName}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#if this.isTesting}}
|
||||
<GalleryContent
|
||||
@handleBackgroundClick={{this.handleBackgroundClick}}
|
||||
@bindKeyboard={{this.bindKeyboard}}
|
||||
@handleKeydown={{this.handleKeydown}}
|
||||
@copyEventId={{this.copyEventId}}
|
||||
@isCreator={{this.isCreator}}
|
||||
@deletePhotoTask={{this.deletePhotoTask}}
|
||||
@handleClose={{this.handleClose}}
|
||||
@photos={{@photos}}
|
||||
@currentPhoto={{this.currentPhoto}}
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#in-element this.destinationElement}}
|
||||
<GalleryContent
|
||||
@handleBackgroundClick={{this.handleBackgroundClick}}
|
||||
@bindKeyboard={{this.bindKeyboard}}
|
||||
@handleKeydown={{this.handleKeydown}}
|
||||
@copyEventId={{this.copyEventId}}
|
||||
@isCreator={{this.isCreator}}
|
||||
@deletePhotoTask={{this.deletePhotoTask}}
|
||||
@handleClose={{this.handleClose}}
|
||||
@photos={{@photos}}
|
||||
@currentPhoto={{this.currentPhoto}}
|
||||
@handleVisiblePhotoChange={{this.handleVisiblePhotoChange}}
|
||||
@placeName={{@placeName}}
|
||||
@selectPhoto={{this.selectPhoto}}
|
||||
/>
|
||||
{{/in-element}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user