diff --git a/docs/content/scripts/content/google-maps.md b/docs/content/scripts/content/google-maps.md index 48471797..20451942 100644 --- a/docs/content/scripts/content/google-maps.md +++ b/docs/content/scripts/content/google-maps.md @@ -380,6 +380,278 @@ By providing your own placeholder slot you will disable the default placeholder ``` +## Google Maps SFC Components + +Nuxt Scripts provides individual Single File Components (SFCs) for different Google Maps elements. These components allow you to declaratively compose complex maps using Vue's template syntax. + +### Installation + +To use marker clustering functionality, you'll need to install the required peer dependency: + +```bash +npm install @googlemaps/markerclusterer +# or +yarn add @googlemaps/markerclusterer +# or +pnpm add @googlemaps/markerclusterer +``` + +### Available Components + +All Google Maps SFC components must be used within a `` component: + +- `` - Classic markers with icon support +- `` - Modern advanced markers with HTML content +- `` - Customizable pin markers (use within AdvancedMarkerElement) +- `` - Information windows that appear on click +- `` - Groups nearby markers into clusters +- `` - Circular overlays +- `` - Polygon shapes +- `` - Line paths +- `` - Rectangular overlays +- `` - Heatmap visualization + +### Basic Usage + +```vue + +``` + +### Component Composition Patterns + +**Marker Clustering** + +```vue + +``` + +**Heatmap with Data Points** + +```vue + + + +``` + +**See the [SFC Playground Example](https://nuxt-scripts-playground.stackblitz.io/third-parties/google-maps/sfcs) for a comprehensive demonstration.** + +### Component Details + +#### ScriptGoogleMapsMarker + +Classic Google Maps marker with icon support. + +**Props:** +- `options` - `google.maps.MarkerOptions` (excluding `map`) + +**Events:** +- Standard marker events: `click`, `mousedown`, `mouseover`, etc. + +#### ScriptGoogleMapsAdvancedMarkerElement + +Modern advanced markers that support HTML content and better customization. + +**Props:** +- `options` - `google.maps.marker.AdvancedMarkerElementOptions` (excluding `map`) + +**Events:** +- Standard marker events: `click`, `drag`, `position_changed`, etc. + +#### ScriptGoogleMapsInfoWindow + +Information windows that display content when triggered. + +**Props:** +- `options` - `google.maps.InfoWindowOptions` + +**Behavior:** +- Automatically opens on parent marker click +- Can be used standalone with explicit position +- Supports custom HTML content via default slot + +#### ScriptGoogleMapsMarkerClusterer + +Groups nearby markers into clusters for better performance and UX. + +**Props:** +- `options` - `MarkerClustererOptions` (excluding `map`) + +**Dependencies:** +- Requires `@googlemaps/markerclusterer` peer dependency + +#### Other Components + +- **ScriptGoogleMapsPinElement**: Use within AdvancedMarkerElement for customizable pins +- **ScriptGoogleMapsCircle**: Circular overlays with radius and styling +- **ScriptGoogleMapsPolygon/Polyline**: Shape and line overlays +- **ScriptGoogleMapsRectangle**: Rectangular overlays +- **ScriptGoogleMapsHeatmapLayer**: Data visualization with heatmaps + +All components support: +- Reactive `options` prop that updates the underlying Google Maps object +- Automatic cleanup on component unmount +- TypeScript support with Google Maps types + +### Best Practices + +#### Performance Considerations + +**Use MarkerClusterer for Many Markers** +```vue + + + + + + + +``` + +**Prefer AdvancedMarkerElement for Modern Apps** +```vue + + + + + + + +``` + +#### Component Hierarchy + +Components must follow this nesting structure: + +``` +ScriptGoogleMaps (root) +├── ScriptGoogleMapsMarkerClusterer (optional) +│ └── ScriptGoogleMapsMarker/AdvancedMarkerElement +│ └── ScriptGoogleMapsInfoWindow (optional) +├── ScriptGoogleMapsAdvancedMarkerElement +│ ├── ScriptGoogleMapsPinElement (optional) +│ └── ScriptGoogleMapsInfoWindow (optional) +└── Other overlays (Circle, Polygon, etc.) +``` + +#### Reactive Data Patterns + +**Reactive Marker Updates** +```vue + + + +``` + +#### Error Handling + +Always provide error fallbacks and loading states: + +```vue + + + +``` + ## useScriptGoogleMaps The `useScriptGoogleMaps` composable lets you have fine-grain control over the Google Maps SDK. It provides a way to load the Google Maps SDK and interact with it programmatically. diff --git a/eslint.config.js b/eslint.config.js index 44406c9a..552ddcdd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -36,6 +36,8 @@ export default createConfigForNuxt({ 'vue/no-multiple-template-root': 'off', // NOTE: Disable this style rules if stylistic is not enabled 'vue/max-attributes-per-line': 'off', + // Disabled for Google Maps SFC components that use conditional rendering without root elements + 'vue/valid-template-root': 'off', }, }) .append({ diff --git a/package.json b/package.json index 1ffaf19d..c0156211 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,9 @@ ] }, "peerDependencies": { - "@stripe/stripe-js": "^7.0.0", + "@googlemaps/markerclusterer": "^2.6.2", "@paypal/paypal-js": "^8.1.2", + "@stripe/stripe-js": "^7.0.0", "@types/google.maps": "^3.58.1", "@types/vimeo__player": "^2.18.3", "@types/youtube": "^0.1.0", @@ -124,6 +125,7 @@ "@paypal/paypal-js": "^8.4.2", "@types/semver": "^7.7.1", "@typescript-eslint/typescript-estree": "^8.44.0", + "@vue/test-utils": "^2.4.6", "acorn-loose": "^8.5.2", "bumpp": "^10.2.3", "changelogen": "^0.6.2", diff --git a/playground/pages/third-parties/google-maps/sfcs.vue b/playground/pages/third-parties/google-maps/sfcs.vue new file mode 100644 index 00000000..cf58f3fb --- /dev/null +++ b/playground/pages/third-parties/google-maps/sfcs.vue @@ -0,0 +1,280 @@ + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 862be25b..0c99fe9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: .: dependencies: + '@googlemaps/markerclusterer': + specifier: ^2.6.2 + version: 2.6.2 '@nuxt/kit': specifier: 'catalog:' version: 4.1.2(magicast@0.3.5) @@ -101,7 +104,7 @@ importers: version: 'link:' '@nuxt/test-utils': specifier: 3.19.2 - version: 3.19.2(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) + version: 3.19.2(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) '@paypal/paypal-js': specifier: ^8.4.2 version: 8.4.2 @@ -111,6 +114,9 @@ importers: '@typescript-eslint/typescript-estree': specifier: ^8.44.0 version: 8.44.0(typescript@5.9.2) + '@vue/test-utils': + specifier: ^2.4.6 + version: 2.4.6 acorn-loose: specifier: ^8.5.2 version: 8.5.2 @@ -676,6 +682,9 @@ packages: '@floating-ui/vue@1.1.9': resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==} + '@googlemaps/markerclusterer@2.6.2': + resolution: {integrity: sha512-U6uVhq8iWhiIckA89sgRu8OK35mjd6/3CuoZKWakKEf0QmRRWpatlsPb3kqXkoWSmbcZkopRiI4dnW6DQSd7bQ==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1004,6 +1013,9 @@ packages: resolution: {integrity: sha512-DUhX92lnCJD6tpghUmfmRIsSIoiXMS2SQ2Yd9Tg1+SnZskiKX+DGwLeAeHX8r0/9Pl/bTDpmYhs1snWcCoIkXA==} engines: {node: '>=18.0.0'} + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1899,6 +1911,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/google.maps@3.58.1': resolution: {integrity: sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==} @@ -1933,6 +1948,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/supercluster@7.1.3': + resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2396,6 +2414,9 @@ packages: '@vue/shared@3.5.21': resolution: {integrity: sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==} + '@vue/test-utils@2.4.6': + resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} + '@vueuse/core@10.11.1': resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} @@ -2529,6 +2550,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2940,6 +2965,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -2974,6 +3003,9 @@ packages: confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -3258,6 +3290,11 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -3585,6 +3622,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.2.2: + resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==} + engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -4135,6 +4176,15 @@ packages: resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} hasBin: true + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4198,6 +4248,9 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + kdbush@4.0.2: + resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4557,6 +4610,10 @@ packages: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} + minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -4713,6 +4770,11 @@ packages: node-releases@2.0.21: resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5217,6 +5279,9 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protocols@2.0.2: resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==} @@ -5674,6 +5739,9 @@ packages: peerDependencies: postcss: ^8.4.32 + supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + superjson@2.2.2: resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==} engines: {node: '>=16'} @@ -6984,6 +7052,12 @@ snapshots: - '@vue/composition-api' - vue + '@googlemaps/markerclusterer@2.6.2': + dependencies: + '@types/supercluster': 7.1.3 + fast-equals: 5.2.2 + supercluster: 8.0.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -7620,7 +7694,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/test-utils@3.19.2(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))': + '@nuxt/test-utils@3.19.2(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@nuxt/kit': 3.19.2(magicast@0.3.5) c12: 3.3.0(magicast@0.3.5) @@ -7644,9 +7718,10 @@ snapshots: tinyexec: 1.0.1 ufo: 1.6.1 unplugin: 2.3.10 - vitest-environment-nuxt: 1.0.1(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) + vitest-environment-nuxt: 1.0.1(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) vue: 3.5.21(typescript@5.9.2) optionalDependencies: + '@vue/test-utils': 2.4.6 happy-dom: 18.0.1 playwright-core: 1.55.0 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1) @@ -7964,6 +8039,8 @@ snapshots: - vite - vue + '@one-ini/wasm@0.1.1': {} + '@opentelemetry/api@1.9.0': {} '@oxc-minify/binding-android-arm64@0.87.0': @@ -8660,6 +8737,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/google.maps@3.58.1': {} '@types/hast@3.0.4': @@ -8692,6 +8771,10 @@ snapshots: '@types/semver@7.7.1': {} + '@types/supercluster@7.1.3': + dependencies: + '@types/geojson': 7946.0.16 + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -9348,6 +9431,11 @@ snapshots: '@vue/shared@3.5.21': {} + '@vue/test-utils@2.4.6': + dependencies: + js-beautify: 1.15.4 + vue-component-type-helpers: 2.2.12 + '@vueuse/core@10.11.1(vue@3.5.21(typescript@5.9.2))': dependencies: '@types/web-bluetooth': 0.0.20 @@ -9500,6 +9588,8 @@ snapshots: '@xtuc/long@4.2.2': {} + abbrev@2.0.0: {} + abbrev@3.0.1: {} abort-controller@3.0.0: @@ -9934,6 +10024,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@11.1.0: {} commander@2.20.3: {} @@ -9961,6 +10053,11 @@ snapshots: confbox@0.2.2: {} + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + consola@3.4.2: {} convert-gitmoji@0.1.5: {} @@ -10204,6 +10301,13 @@ snapshots: eastasianwidth@0.2.0: {} + editorconfig@1.0.4: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.7.2 + ee-first@1.1.1: {} electron-to-chromium@1.5.221: {} @@ -10599,6 +10703,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.2.2: {} + fast-fifo@1.3.2: {} fast-glob@3.3.3: @@ -11237,6 +11343,16 @@ snapshots: jiti@2.5.1: {} + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 10.4.5 + js-cookie: 3.0.5 + nopt: 7.2.1 + + js-cookie@3.0.5: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -11283,6 +11399,8 @@ snapshots: jsonc-parser@3.3.1: {} + kdbush@4.0.2: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -11808,6 +11926,10 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimatch@9.0.1: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -12027,6 +12149,10 @@ snapshots: node-releases@2.0.21: {} + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + nopt@8.1.0: dependencies: abbrev: 3.0.1 @@ -12832,6 +12958,8 @@ snapshots: property-information@7.1.0: {} + proto-list@1.2.4: {} + protocols@2.0.2: {} pump@3.0.3: @@ -13437,6 +13565,10 @@ snapshots: postcss: 8.5.6 postcss-selector-parser: 7.1.0 + supercluster@8.0.1: + dependencies: + kdbush: 4.0.2 + superjson@2.2.2: dependencies: copy-anything: 3.0.5 @@ -14124,9 +14256,9 @@ snapshots: terser: 5.44.0 yaml: 2.8.1 - vitest-environment-nuxt@1.0.1(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)): + vitest-environment-nuxt@1.0.1(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)): dependencies: - '@nuxt/test-utils': 3.19.2(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) + '@nuxt/test-utils': 3.19.2(@vue/test-utils@2.4.6)(happy-dom@18.0.1)(magicast@0.3.5)(playwright-core@1.55.0)(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.5.1)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1)) transitivePeerDependencies: - '@cucumber/cucumber' - '@jest/globals' diff --git a/src/module.ts b/src/module.ts index 092d355a..46e6c189 100644 --- a/src/module.ts +++ b/src/module.ts @@ -145,6 +145,7 @@ export default defineNuxtModule({ addComponentsDir({ path: await resolvePath('./runtime/components'), + pathPrefix: false, }) const scripts = await registry(resolvePath) as (RegistryScript & { _importRegistered?: boolean })[] diff --git a/src/runtime/components/ScriptGoogleMaps.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue similarity index 94% rename from src/runtime/components/ScriptGoogleMaps.vue rename to src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index c771d6a6..fc0df6db 100644 --- a/src/runtime/components/ScriptGoogleMaps.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -1,21 +1,25 @@ - + + + diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue new file mode 100644 index 00000000..0c45cb0c --- /dev/null +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsCircle.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsHeatmapLayer.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsHeatmapLayer.vue new file mode 100644 index 00000000..7250968b --- /dev/null +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsHeatmapLayer.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsInfoWindow.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsInfoWindow.vue new file mode 100644 index 00000000..39c3f9a5 --- /dev/null +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsInfoWindow.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarker.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarker.vue new file mode 100644 index 00000000..d4ac0ccb --- /dev/null +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarker.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue new file mode 100644 index 00000000..222ee9f8 --- /dev/null +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue new file mode 100644 index 00000000..0ef56248 --- /dev/null +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue new file mode 100644 index 00000000..921a8383 --- /dev/null +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolygon.vue @@ -0,0 +1,76 @@ + + + diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue new file mode 100644 index 00000000..2448d16d --- /dev/null +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPolyline.vue @@ -0,0 +1,76 @@ + + + diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue new file mode 100644 index 00000000..fd2d0609 --- /dev/null +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsRectangle.vue @@ -0,0 +1,77 @@ + + + diff --git a/test/unit/__helpers__/google-maps-test-utils.ts b/test/unit/__helpers__/google-maps-test-utils.ts new file mode 100644 index 00000000..7ecd53f0 --- /dev/null +++ b/test/unit/__helpers__/google-maps-test-utils.ts @@ -0,0 +1,160 @@ +import { vi } from 'vitest' +import { ref } from 'vue' +import type { createMockGoogleMapsAPI } from '../__mocks__/google-maps-api' + +export type MocksType = ReturnType + +/** + * Creates a mock map context that components would inject + */ +export const createMockMapContext = (mocks: MocksType) => ({ + map: ref({}), + mapsApi: ref(mocks.mockMapsApi), +}) + +/** + * Simulates the component lifecycle for markers + */ +export const simulateMarkerLifecycle = (mocks: MocksType, options: any) => { + // Creation + const marker = new mocks.mockMapsApi.Marker(options) + + // Event listener setup (simulate what components do) + marker.addListener('click', vi.fn()) + marker.addListener('position_changed', vi.fn()) + + // Map assignment + marker.setMap(ref({}).value) + + // Options update + const newOptions = { ...options, title: 'Updated' } + marker.setOptions(newOptions) + + // Cleanup + mocks.mockMapsApi.event.clearInstanceListeners(marker) + marker.setMap(null) + + return marker +} + +/** + * Simulates the component lifecycle for advanced markers + */ +export const simulateAdvancedMarkerLifecycle = async (mocks: MocksType, options: any) => { + // Library import (required for advanced markers) + await mocks.mockMapsApi.importLibrary('marker') + + // Creation + const advancedMarker = new mocks.mockMapsApi.marker.AdvancedMarkerElement(options) + + // Event listener setup + advancedMarker.addListener('click', vi.fn()) + advancedMarker.addListener('position_changed', vi.fn()) + + // Map assignment + advancedMarker.map = ref({}).value + + // Options update using Object.assign (as components do) + const newOptions = { position: { lat: 20, lng: 30 } } + Object.assign(advancedMarker, newOptions) + + // Cleanup + mocks.mockMapsApi.event.clearInstanceListeners(advancedMarker) + advancedMarker.map = null + + return advancedMarker +} + +/** + * Simulates info window lifecycle and attachment patterns + */ +export const simulateInfoWindowLifecycle = (mocks: MocksType, options: any) => { + // Create DOM element for content + const contentElement = document.createElement('div') + contentElement.innerHTML = '

Test content

' + + // Creation with content + const infoWindow = new mocks.mockMapsApi.InfoWindow({ + content: contentElement, + ...options, + }) + + // Event listener setup + infoWindow.addListener('close', vi.fn()) + infoWindow.addListener('domready', vi.fn()) + + // Opening on map + infoWindow.open({ map: ref({}).value, anchor: undefined }) + + // Cleanup + mocks.mockMapsApi.event.clearInstanceListeners(infoWindow) + infoWindow.close() + + return { infoWindow, contentElement } +} + +/** + * Simulates marker clusterer with multiple markers + */ +export const simulateMarkerClustererLifecycle = async (mocks: MocksType, options: any = {}) => { + const { MarkerClusterer } = await import('@googlemaps/markerclusterer') + + // Create clusterer + const clusterer = new MarkerClusterer({ + map: ref({}).value, + ...options, + }) + + // Add multiple markers + const markers = [ + new mocks.mockMapsApi.Marker({ position: { lat: 1, lng: 1 } }), + new mocks.mockMapsApi.Marker({ position: { lat: 2, lng: 2 } }), + new mocks.mockMapsApi.Marker({ position: { lat: 3, lng: 3 } }), + ] + + markers.forEach((marker) => { + clusterer.addMarker(marker) + }) + + // Simulate marker removal + clusterer.removeMarker(markers[0], true) + + // Trigger rerender + clusterer.render() + + // Event listeners + clusterer.addListener('clusteringbegin', vi.fn()) + clusterer.addListener('clusteringend', vi.fn()) + + // Cleanup + clusterer.setMap(null) + + return { clusterer, markers } +} + +/** + * Test options for various Google Maps objects + */ +export const TEST_OPTIONS = { + marker: { + position: { lat: -33.8688, lng: 151.2093 }, + title: 'Test Marker', + draggable: true, + }, + advancedMarker: { + position: { lat: -33.8688, lng: 151.2093 }, + title: 'Advanced Test Marker', + }, + infoWindow: { + position: { lat: -33.8688, lng: 151.2093 }, + maxWidth: 300, + }, + clusterer: { + gridSize: 60, + maxZoom: 15, + }, + pin: { + scale: 1.5, + background: '#FF0000', + }, +} as const diff --git a/test/unit/__mocks__/google-maps-api.ts b/test/unit/__mocks__/google-maps-api.ts new file mode 100644 index 00000000..ebc1ffa8 --- /dev/null +++ b/test/unit/__mocks__/google-maps-api.ts @@ -0,0 +1,114 @@ +import { vi } from 'vitest' + +export const createMockMarker = () => ({ + setOptions: vi.fn(), + setMap: vi.fn(), + addListener: vi.fn(), +}) + +export const createMockAdvancedMarkerElement = () => ({ + map: null, + content: null, + position: null, + addListener: vi.fn(), +}) + +export const createMockInfoWindow = () => ({ + setOptions: vi.fn(), + setPosition: vi.fn(), + open: vi.fn(), + close: vi.fn(), + addListener: vi.fn(), +}) + +export const createMockPinElement = () => ({ + element: document.createElement('div'), +}) + +export const createMockMarkerClusterer = () => ({ + addMarker: vi.fn(), + removeMarker: vi.fn(), + setMap: vi.fn(), + addListener: vi.fn(), + render: vi.fn(), +}) + +export const createMockGoogleMapsAPI = () => { + const mockMarker = createMockMarker() + const mockAdvancedMarkerElement = createMockAdvancedMarkerElement() + const mockInfoWindow = createMockInfoWindow() + const mockPinElement = createMockPinElement() + const mockMarkerClusterer = createMockMarkerClusterer() + + const mockMapsApi = { + Marker: vi.fn(() => mockMarker), + marker: { + AdvancedMarkerElement: vi.fn(() => mockAdvancedMarkerElement), + PinElement: vi.fn(() => mockPinElement), + }, + InfoWindow: vi.fn(() => mockInfoWindow), + event: { + clearInstanceListeners: vi.fn(), + }, + importLibrary: vi.fn().mockResolvedValue({ + AdvancedMarkerElement: vi.fn(() => mockAdvancedMarkerElement), + PinElement: vi.fn(() => mockPinElement), + }), + LatLng: vi.fn((lat: number, lng: number) => ({ lat, lng })), + } + + return { + mockMarker, + mockAdvancedMarkerElement, + mockInfoWindow, + mockMarkerClusterer, + mockPinElement, + mockMapsApi, + } +} + +// Standard event types used across components +export const MARKER_EVENTS_WITHOUT_PAYLOAD = [ + 'animation_changed', + 'clickable_changed', + 'cursor_changed', + 'draggable_changed', + 'flat_changed', + 'icon_changed', + 'position_changed', + 'shape_changed', + 'title_changed', + 'visible_changed', + 'zindex_changed', +] as const + +export const MARKER_EVENTS_WITH_MOUSE_EVENT = [ + 'click', + 'contextmenu', + 'dblclick', + 'drag', + 'dragend', + 'dragstart', + 'mousedown', + 'mouseout', + 'mouseover', + 'mouseup', +] as const + +export const INFO_WINDOW_EVENTS = [ + 'close', + 'closeclick', + 'content_changed', + 'domready', + 'headercontent_changed', + 'headerdisabled_changed', + 'position_changed', + 'visible', + 'zindex_changed', +] as const + +export const MARKER_CLUSTERER_EVENTS = [ + 'click', + 'clusteringbegin', + 'clusteringend', +] as const diff --git a/test/unit/__mocks__/markerclusterer.ts b/test/unit/__mocks__/markerclusterer.ts new file mode 100644 index 00000000..2d4acaa4 --- /dev/null +++ b/test/unit/__mocks__/markerclusterer.ts @@ -0,0 +1,15 @@ +import { vi } from 'vitest' + +export const MarkerClusterer = vi.fn().mockImplementation(options => ({ + addMarker: vi.fn(), + removeMarker: vi.fn(), + setMap: vi.fn(), + addListener: vi.fn(), + render: vi.fn(), + ...options, +})) + +// Mock the entire module +const mockMarkerClusterer = { MarkerClusterer } + +export default mockMarkerClusterer diff --git a/test/unit/google-maps-components.test.ts b/test/unit/google-maps-components.test.ts new file mode 100644 index 00000000..deb70dc6 --- /dev/null +++ b/test/unit/google-maps-components.test.ts @@ -0,0 +1,304 @@ +/** + * @vitest-environment happy-dom + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' +import { + createMockGoogleMapsAPI, + MARKER_EVENTS_WITHOUT_PAYLOAD, + MARKER_EVENTS_WITH_MOUSE_EVENT, + INFO_WINDOW_EVENTS, + MARKER_CLUSTERER_EVENTS, +} from './__mocks__/google-maps-api' +import { + simulateMarkerLifecycle, + simulateAdvancedMarkerLifecycle, + simulateInfoWindowLifecycle, + simulateMarkerClustererLifecycle, + TEST_OPTIONS, + type MocksType, +} from './__helpers__/google-maps-test-utils' + +// Mock @googlemaps/markerclusterer module +vi.mock('@googlemaps/markerclusterer', async () => { + const { default: mockMarkerClusterer } = await import('./__mocks__/markerclusterer') + return mockMarkerClusterer +}) + +describe('Google Maps SFC Components Logic', () => { + let mocks: MocksType + + beforeEach(() => { + mocks = createMockGoogleMapsAPI() + vi.clearAllMocks() + }) + + describe('Google Maps API Integration', () => { + it('should create marker with provided options', () => { + const options = TEST_OPTIONS.marker + + // Simulate what ScriptGoogleMapsMarker component does + const marker = new mocks.mockMapsApi.Marker(options) + + expect(mocks.mockMapsApi.Marker).toHaveBeenCalledWith(options) + expect(marker).toBe(mocks.mockMarker) + }) + + it('should create advanced marker element with position', async () => { + const options = { position: { lat: 15, lng: 25 } } + + // Simulate what ScriptGoogleMapsAdvancedMarkerElement component does + await mocks.mockMapsApi.importLibrary('marker') + const advancedMarker = new mocks.mockMapsApi.marker.AdvancedMarkerElement(options) + + expect(mocks.mockMapsApi.importLibrary).toHaveBeenCalledWith('marker') + expect(mocks.mockMapsApi.marker.AdvancedMarkerElement).toHaveBeenCalledWith(options) + expect(advancedMarker).toBe(mocks.mockAdvancedMarkerElement) + }) + + it('should create info window with content', () => { + const mockElement = document.createElement('div') + mockElement.innerHTML = '

Test content

' + const options = { content: mockElement, position: { lat: 0, lng: 0 } } + + // Simulate what ScriptGoogleMapsInfoWindow component does + const infoWindow = new mocks.mockMapsApi.InfoWindow(options) + + expect(mocks.mockMapsApi.InfoWindow).toHaveBeenCalledWith(options) + expect(infoWindow).toBe(mocks.mockInfoWindow) + }) + + it('should create pin element for advanced markers', async () => { + const options = { scale: 1.5, background: '#FF0000' } + + // Simulate what ScriptGoogleMapsPinElement component does + await mocks.mockMapsApi.importLibrary('marker') + const pinElement = new mocks.mockMapsApi.marker.PinElement(options) + + expect(pinElement).toBe(mocks.mockPinElement) + expect(pinElement.element).toBeInstanceOf(HTMLElement) + }) + }) + + describe('Marker Clustering Logic', () => { + it('should create marker clusterer with map and options', async () => { + const { MarkerClusterer } = await import('@googlemaps/markerclusterer') + const mockMap = ref({}) + const options = { gridSize: 60 } + + // Simulate what ScriptGoogleMapsMarkerClusterer component does + new MarkerClusterer({ + map: mockMap.value, + ...options, + }) + + expect(MarkerClusterer).toHaveBeenCalledWith({ + map: mockMap.value, + gridSize: 60, + }) + }) + + it('should handle marker removal and trigger rerender', async () => { + const clusterer = mocks.mockMarkerClusterer + const marker = mocks.mockMarker + + // Simulate marker removal logic from ScriptGoogleMapsMarker + clusterer.removeMarker(marker, true) + + expect(clusterer.removeMarker).toHaveBeenCalledWith(marker, true) + }) + }) + + describe('Event Handling Patterns', () => { + it('should register standard marker events without payload', () => { + const marker = mocks.mockMarker + + // Simulate event registration logic from components + MARKER_EVENTS_WITHOUT_PAYLOAD.forEach((eventType) => { + const handler = vi.fn() + marker.addListener(eventType, handler) + }) + + expect(marker.addListener).toHaveBeenCalledTimes(MARKER_EVENTS_WITHOUT_PAYLOAD.length) + MARKER_EVENTS_WITHOUT_PAYLOAD.forEach((eventType) => { + expect(marker.addListener).toHaveBeenCalledWith(eventType, expect.any(Function)) + }) + }) + + it('should register marker events with mouse event payload', () => { + const marker = mocks.mockMarker + + // Simulate event registration logic from components + MARKER_EVENTS_WITH_MOUSE_EVENT.forEach((eventType) => { + const handler = vi.fn() + marker.addListener(eventType, handler) + }) + + expect(marker.addListener).toHaveBeenCalledTimes(MARKER_EVENTS_WITH_MOUSE_EVENT.length) + MARKER_EVENTS_WITH_MOUSE_EVENT.forEach((eventType) => { + expect(marker.addListener).toHaveBeenCalledWith(eventType, expect.any(Function)) + }) + }) + + it('should register info window events', () => { + const infoWindow = mocks.mockInfoWindow + + // Simulate event registration logic + INFO_WINDOW_EVENTS.forEach((eventType) => { + const handler = vi.fn() + infoWindow.addListener(eventType, handler) + }) + + expect(infoWindow.addListener).toHaveBeenCalledTimes(INFO_WINDOW_EVENTS.length) + INFO_WINDOW_EVENTS.forEach((eventType) => { + expect(infoWindow.addListener).toHaveBeenCalledWith(eventType, expect.any(Function)) + }) + }) + + it('should register marker clusterer events', () => { + const clusterer = mocks.mockMarkerClusterer + + // Simulate event registration logic + MARKER_CLUSTERER_EVENTS.forEach((eventType) => { + const handler = vi.fn() + clusterer.addListener(eventType, handler) + }) + + expect(clusterer.addListener).toHaveBeenCalledTimes(MARKER_CLUSTERER_EVENTS.length) + MARKER_CLUSTERER_EVENTS.forEach((eventType) => { + expect(clusterer.addListener).toHaveBeenCalledWith(eventType, expect.any(Function)) + }) + }) + }) + + describe('Component Lifecycle Patterns', () => { + it('should clean up event listeners on unmount', () => { + const marker = mocks.mockMarker + + // Simulate unmount cleanup logic + mocks.mockMapsApi.event.clearInstanceListeners(marker) + marker.setMap(null) + + expect(mocks.mockMapsApi.event.clearInstanceListeners).toHaveBeenCalledWith(marker) + expect(marker.setMap).toHaveBeenCalledWith(null) + }) + + it('should update options reactively', () => { + const marker = mocks.mockMarker + const newOptions = { position: { lat: 30, lng: 40 }, title: 'Updated' } + + // Simulate reactive options update from components + marker.setOptions(newOptions) + + expect(marker.setOptions).toHaveBeenCalledWith(newOptions) + }) + }) + + describe('Component Composition Patterns', () => { + it('should attach info window to marker', () => { + const marker = mocks.mockMarker + const infoWindow = mocks.mockInfoWindow + const mockMap = ref({}) + + // Simulate info window attachment logic + marker.addListener('click', () => { + infoWindow.open({ + anchor: marker, + map: mockMap.value, + }) + }) + + expect(marker.addListener).toHaveBeenCalledWith('click', expect.any(Function)) + }) + + it('should assign pin element to advanced marker', async () => { + const advancedMarker = mocks.mockAdvancedMarkerElement + const pinElement = mocks.mockPinElement + + // Simulate pin element assignment + advancedMarker.content = pinElement.element + + expect(advancedMarker.content).toBe(pinElement.element) + }) + + it('should add markers to clusterer', () => { + const clusterer = mocks.mockMarkerClusterer + const marker1 = mocks.mockMarker + const marker2 = { ...mocks.mockMarker } + + // Simulate adding markers to clusterer + clusterer.addMarker(marker1) + clusterer.addMarker(marker2) + + expect(clusterer.addMarker).toHaveBeenCalledTimes(2) + expect(clusterer.addMarker).toHaveBeenCalledWith(marker1) + expect(clusterer.addMarker).toHaveBeenCalledWith(marker2) + }) + }) + + describe('Google Maps API Types and Integration', () => { + it('should work with LatLng objects', () => { + const lat = -33.8688 + const lng = 151.2093 + const latLng = new mocks.mockMapsApi.LatLng(lat, lng) + + expect(mocks.mockMapsApi.LatLng).toHaveBeenCalledWith(lat, lng) + expect(latLng).toEqual({ lat, lng }) + }) + + it('should handle library importing for advanced features', async () => { + // Test the library import pattern used by advanced components + const markerLibrary = await mocks.mockMapsApi.importLibrary('marker') + const visualizationLibrary = await mocks.mockMapsApi.importLibrary('visualization') + + expect(mocks.mockMapsApi.importLibrary).toHaveBeenCalledWith('marker') + expect(mocks.mockMapsApi.importLibrary).toHaveBeenCalledWith('visualization') + expect(markerLibrary).toBeDefined() + expect(visualizationLibrary).toBeDefined() + }) + }) + + describe('Component Lifecycle Simulations', () => { + it('should simulate complete marker lifecycle', () => { + const marker = simulateMarkerLifecycle(mocks, TEST_OPTIONS.marker) + + expect(mocks.mockMapsApi.Marker).toHaveBeenCalledWith(TEST_OPTIONS.marker) + expect(marker.addListener).toHaveBeenCalledWith('click', expect.any(Function)) + expect(marker.addListener).toHaveBeenCalledWith('position_changed', expect.any(Function)) + expect(marker.setMap).toHaveBeenCalledWith(null) // cleanup + expect(mocks.mockMapsApi.event.clearInstanceListeners).toHaveBeenCalledWith(marker) + }) + + it('should simulate complete advanced marker lifecycle', async () => { + const advancedMarker = await simulateAdvancedMarkerLifecycle(mocks, TEST_OPTIONS.advancedMarker) + + expect(mocks.mockMapsApi.importLibrary).toHaveBeenCalledWith('marker') + expect(mocks.mockMapsApi.marker.AdvancedMarkerElement).toHaveBeenCalledWith(TEST_OPTIONS.advancedMarker) + expect(advancedMarker.addListener).toHaveBeenCalledWith('click', expect.any(Function)) + expect(advancedMarker.addListener).toHaveBeenCalledWith('position_changed', expect.any(Function)) + expect(advancedMarker.map).toBe(null) // cleaned up + }) + + it('should simulate info window with content lifecycle', () => { + const { infoWindow, contentElement } = simulateInfoWindowLifecycle(mocks, TEST_OPTIONS.infoWindow) + + expect(mocks.mockMapsApi.InfoWindow).toHaveBeenCalled() + expect(contentElement).toBeInstanceOf(HTMLElement) + expect(contentElement.innerHTML).toBe('

Test content

') + expect(infoWindow.addListener).toHaveBeenCalledWith('close', expect.any(Function)) + expect(infoWindow.addListener).toHaveBeenCalledWith('domready', expect.any(Function)) + expect(infoWindow.close).toHaveBeenCalled() + }) + + it('should simulate marker clusterer with multiple markers', async () => { + const { clusterer, markers } = await simulateMarkerClustererLifecycle(mocks, TEST_OPTIONS.clusterer) + const { MarkerClusterer } = await import('@googlemaps/markerclusterer') + + expect(MarkerClusterer).toHaveBeenCalledWith(expect.objectContaining(TEST_OPTIONS.clusterer)) + expect(clusterer.addMarker).toHaveBeenCalledTimes(3) + expect(clusterer.removeMarker).toHaveBeenCalledWith(markers[0], true) + expect(clusterer.render).toHaveBeenCalled() + expect(clusterer.setMap).toHaveBeenCalledWith(null) + }) + }) +})