diff --git a/app/components/map.gjs b/app/components/map.gjs index 09dc19c..b83b98b 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -33,6 +33,7 @@ export default class MapComponent extends Component { selectedPinElement; crosshairElement; crosshairOverlay; + ignoreNextMapClick = false; setupMap = modifier((element) => { if (this.mapInstance) return; @@ -169,6 +170,18 @@ export default class MapComponent extends Component { }); this.mapInstance.addOverlay(this.locationOverlay); + // Track search box focus state on pointer down to handle race conditions + // The blur event fires before click, so we need to capture state here + element.addEventListener( + 'pointerdown', + () => { + if (this.mapUi.searchBoxHasFocus) { + this.ignoreNextMapClick = true; + } + }, + true + ); + // Geolocation Setup const geolocation = new Geolocation({ trackingOptions: { @@ -711,6 +724,11 @@ export default class MapComponent extends Component { }; handleMapClick = async (event) => { + if (this.ignoreNextMapClick) { + this.ignoreNextMapClick = false; + return; + } + // Check if user clicked on a rendered feature (POI or Bookmark) FIRST const features = this.mapInstance.getFeaturesAtPixel(event.pixel, { hitTolerance: 10, diff --git a/app/components/search-box.gjs b/app/components/search-box.gjs index ae08786..03e5860 100644 --- a/app/components/search-box.gjs +++ b/app/components/search-box.gjs @@ -4,7 +4,7 @@ import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { on } from '@ember/modifier'; import { fn } from '@ember/helper'; -import { debounce } from '@ember/runloop'; +import { task, timeout } from 'ember-concurrency'; import Icon from '#components/icon'; export default class SearchBoxComponent extends Component { @@ -30,10 +30,12 @@ export default class SearchBoxComponent extends Component { return; } - debounce(this, this.performSearch, 300); + this.searchTask.perform(); } - async performSearch() { + searchTask = task({ restartable: true }, async () => { + await timeout(300); + if (this.query.length < 2) return; this.isLoading = true; @@ -51,13 +53,14 @@ export default class SearchBoxComponent extends Component { } finally { this.isLoading = false; } - } + }); @action handleFocus() { this.isFocused = true; + this.mapUi.setSearchBoxFocus(true); if (this.query.length >= 2 && this.results.length === 0) { - this.performSearch(); + this.searchTask.perform(); } } @@ -66,7 +69,8 @@ export default class SearchBoxComponent extends Component { // Delay hiding so clicks on results can register setTimeout(() => { this.isFocused = false; - }, 200); + this.mapUi.setSearchBoxFocus(false); + }, 300); } @action @@ -143,11 +147,7 @@ export default class SearchBoxComponent extends Component { autocomplete="off" /> - diff --git a/app/services/map-ui.js b/app/services/map-ui.js index 239c8d5..f595395 100644 --- a/app/services/map-ui.js +++ b/app/services/map-ui.js @@ -8,6 +8,7 @@ export default class MapUiService extends Service { @tracked creationCoordinates = null; @tracked returnToSearch = false; @tracked currentCenter = null; + @tracked searchBoxHasFocus = false; selectPlace(place) { this.selectedPlace = place; @@ -40,6 +41,10 @@ export default class MapUiService extends Service { this.creationCoordinates = { lat, lon }; } + setSearchBoxFocus(isFocused) { + this.searchBoxHasFocus = isFocused; + } + updateCenter(lat, lon) { this.currentCenter = { lat, lon }; } diff --git a/babel.config.mjs b/babel.config.mjs index 48248a6..13f7d44 100644 --- a/babel.config.mjs +++ b/babel.config.mjs @@ -2,6 +2,9 @@ import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { setConfig } from '@warp-drive/core/build-config'; import { buildMacros } from '@embroider/macros/babel'; +import asyncArrowTaskTransform from 'ember-concurrency/async-arrow-task-transform'; + +console.log('Babel config loading, plugin:', typeof asyncArrowTaskTransform); const macros = buildMacros({ configure: (config) => { @@ -14,6 +17,7 @@ const macros = buildMacros({ export default { plugins: [ + asyncArrowTaskTransform, [ 'babel-plugin-ember-template-compilation', { diff --git a/package.json b/package.json index ff67054..6cc2285 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "edition": "octane" }, "dependencies": { + "ember-concurrency": "^5.2.0", "ember-lifeline": "^7.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daf1b45..bf45253 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + ember-concurrency: + specifier: ^5.2.0 + version: 5.2.0(@babel/core@7.28.6) ember-lifeline: specifier: ^7.0.0 version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6)) @@ -1436,66 +1439,79 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -2519,6 +2535,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decorator-transforms@1.2.1: + resolution: {integrity: sha512-UUtmyfdlHvYoX3VSG1w5rbvBQ2r5TX1JsE4hmKU9snleFymadA3VACjl6SRfi9YgBCSjBbfQvR1bs9PRW9yBKw==} + decorator-transforms@2.3.1: resolution: {integrity: sha512-PDOk74Zqqy0946Lx4ckXxbgG6uhPScOICtrxL/pXmfznxchqNee0TaJISClGJQe6FeT8ohGqsOgdjfahm4FwEw==} @@ -2669,6 +2688,15 @@ packages: engines: {node: '>= 20.19.0'} hasBin: true + ember-concurrency@5.2.0: + resolution: {integrity: sha512-NUptPzaxaF2XWqn3VQ5KqiLSRqPFIZhWXH3UkOMhiedmiolxGYjUV96maoHWdd5msxNgQBC0UkZ28m7pV7A0sQ==} + engines: {node: 16.* || >= 18} + peerDependencies: + '@glint/template': '>= 1.0.0' + peerDependenciesMeta: + '@glint/template': + optional: true + ember-eslint-parser@0.5.13: resolution: {integrity: sha512-b6ALDaxs9Bb4v0uagWud/5lECb78qpXHFv7M340dUHFW4Y0RuhlsfA4Rb+765X1+6KHp8G7TaAs0UgggWUqD3g==} engines: {node: '>=16.0.0'} @@ -8110,6 +8138,13 @@ snapshots: decimal.js@10.6.0: {} + decorator-transforms@1.2.1(@babel/core@7.28.6): + dependencies: + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6) + babel-import-util: 2.1.1 + transitivePeerDependencies: + - '@babel/core' + decorator-transforms@2.3.1(@babel/core@7.28.6): dependencies: '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6) @@ -8462,6 +8497,17 @@ snapshots: - walrus - whiskers + ember-concurrency@5.2.0(@babel/core@7.28.6): + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.28.6 + '@embroider/addon-shim': 1.10.2 + decorator-transforms: 1.2.1(@babel/core@7.28.6) + transitivePeerDependencies: + - '@babel/core' + - supports-color + ember-eslint-parser@0.5.13(@babel/core@7.28.6)(eslint@9.39.2)(typescript@5.9.3): dependencies: '@babel/core': 7.28.6 diff --git a/tests/integration/components/search-box-test.gjs b/tests/integration/components/search-box-test.gjs index f3007ec..cf30a27 100644 --- a/tests/integration/components/search-box-test.gjs +++ b/tests/integration/components/search-box-test.gjs @@ -36,7 +36,8 @@ module('Integration | Component | search-box', function (hooks) { } this.owner.register('service:router', MockRouterService); - await render(); + this.noop = () => {}; + await render(); assert.dom('.search-input').exists(); assert.dom('.search-results-popover').doesNotExist(); @@ -72,20 +73,20 @@ module('Integration | Component | search-box', function (hooks) { // Mock MapUi Service class MockMapUiService extends Service { currentCenter = { lat: 52.52, lon: 13.405 }; + setSearchBoxFocus() {} } this.owner.register('service:map-ui', MockMapUiService); // Mock Router Service class MockRouterService extends Service { transitionTo(routeName, options) { - assert.step( - `transitionTo: ${routeName} ${JSON.stringify(options)}` - ); + assert.step(`transitionTo: ${routeName} ${JSON.stringify(options)}`); } } this.owner.register('service:router', MockRouterService); - await render(); + this.noop = () => {}; + await render(); await fillIn('.search-input', 'berlin'); await click('.search-input'); // Focus @@ -103,6 +104,7 @@ module('Integration | Component | search-box', function (hooks) { // Mock MapUi Service class MockMapUiService extends Service { currentCenter = { lat: 52.52, lon: 13.405 }; + setSearchBoxFocus() {} } this.owner.register('service:map-ui', MockMapUiService); @@ -115,10 +117,11 @@ module('Integration | Component | search-box', function (hooks) { } this.owner.register('service:photon', MockPhotonService); - await render(); + this.noop = () => {}; + await render(); await fillIn('.search-input', 'cafe'); - + // Wait for debounce (300ms) + execution const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); await delay(400);