sprintify-ui 0.12.27 → 0.12.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -111,9 +111,9 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
111
111
  }>, {
112
112
  size: "xs" | "sm" | "md" | "lg" | "xl";
113
113
  options: NormalizedOption[];
114
- loading: boolean;
115
114
  selected: NormalizedOption | NormalizedOption[] | null | undefined;
116
115
  keywords: string;
116
+ loading: boolean;
117
117
  loadingBottom: boolean;
118
118
  optionColor: (option: RawOption) => string;
119
119
  optionIcon: (option: RawOption) => string;
@@ -250,10 +250,10 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
250
250
  modelValue: string | number | null | undefined;
251
251
  hasError: boolean;
252
252
  visibleFocus: boolean;
253
- primaryKey: string;
254
253
  optionColor: (option: RawOption) => string;
255
254
  optionIcon: (option: RawOption) => string;
256
255
  optionSubtitle: (option: RawOption) => string;
256
+ primaryKey: string;
257
257
  dropdownShow: "always" | "focus";
258
258
  showModelValue: boolean;
259
259
  focusOnMount: boolean;
@@ -82,10 +82,10 @@ declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {
82
82
  modelValue: string | number | null;
83
83
  hasError: boolean;
84
84
  visibleFocus: boolean;
85
- primaryKey: string;
86
85
  optionColor: (option: RawOption) => string;
87
86
  optionIcon: (option: RawOption) => string;
88
87
  optionSubtitle: (option: RawOption) => string;
88
+ primaryKey: string;
89
89
  dropdownShow: "focus" | "always";
90
90
  showModelValue: boolean;
91
91
  focusOnMount: boolean;
@@ -164,9 +164,9 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
164
164
  modelValue: string | number | (string | number)[] | null | undefined;
165
165
  hasError: boolean;
166
166
  max: number;
167
- primaryKey: string;
168
167
  optionColor: (option: RawOption) => string;
169
168
  optionIcon: (option: RawOption) => string;
169
+ primaryKey: string;
170
170
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
171
171
  declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
172
172
  export default _default;
@@ -0,0 +1,31 @@
1
+ import { Ref } from 'vue';
2
+ /**
3
+ * Which Google Places API the address autocomplete uses.
4
+ * - `auto`: try Places API (New) first, fall back to the legacy Places API
5
+ * when the API key is not allowed to use the new one.
6
+ * - `new`: always use Places API (New).
7
+ * - `legacy`: always use the legacy Places API (google.maps.places.Autocomplete widget).
8
+ */
9
+ export type GooglePlacesApi = 'auto' | 'new' | 'legacy';
10
+ export type ResolvedGooglePlacesApi = 'new' | 'legacy' | 'unavailable';
11
+ export interface PlaceSuggestion {
12
+ id: string;
13
+ mainText: string;
14
+ secondaryText: string;
15
+ prediction: google.maps.places.PlacePrediction;
16
+ }
17
+ /** Address component, normalized across the legacy and new Places APIs. */
18
+ export interface PlaceAddressComponent {
19
+ types: string[];
20
+ longText: string;
21
+ shortText: string;
22
+ }
23
+ export declare function usePlacesAutocomplete(regionCode: Ref<string | null>, mode: GooglePlacesApi): {
24
+ api: import("vue").ComputedRef<ResolvedGooglePlacesApi | null>;
25
+ usesNewApi: import("vue").ComputedRef<boolean>;
26
+ usesLegacyApi: import("vue").ComputedRef<boolean>;
27
+ fetchSuggestions: (input: string) => Promise<PlaceSuggestion[] | null>;
28
+ fetchAddressComponents: (suggestion: PlaceSuggestion) => Promise<PlaceAddressComponent[]>;
29
+ cancelPendingSuggestions: () => void;
30
+ };
31
+ export declare function normalizeLegacyAddressComponents(components: google.maps.GeocoderAddressComponent[]): PlaceAddressComponent[];
@@ -230,6 +230,7 @@ import { Country } from './types/Country';
230
230
  import { Region } from './types/Region';
231
231
  import { Size } from './utils/sizes';
232
232
  import { SettingsStorage } from './utils/storage';
233
+ import type { GooglePlacesApi } from './composables/placesAutocomplete';
233
234
  export interface OptionProps {
234
235
  size?: Size;
235
236
  }
@@ -243,6 +244,7 @@ export interface Options {
243
244
  countries?: Country[];
244
245
  regions?: Region[];
245
246
  props?: OptionProps;
247
+ googlePlacesApi?: GooglePlacesApi;
246
248
  }
247
249
  declare const config: {
248
250
  http: AxiosInstance;
@@ -253,6 +255,7 @@ declare const config: {
253
255
  settingsStorage: SettingsStorage;
254
256
  countries: Country[];
255
257
  regions: Region[];
258
+ googlePlacesApi: GooglePlacesApi;
256
259
  props: OptionProps | undefined;
257
260
  };
258
261
  declare function install(app: App, options?: Options): void;
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ export default _default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprintify-ui",
3
- "version": "0.12.27",
3
+ "version": "0.12.28",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "generate-llm-txt": "node scripts/generate-llm-txt.js",
@@ -61,7 +61,7 @@
61
61
  "@tiptap/starter-kit": "^3.13.0",
62
62
  "@tiptap/vue-3": "^3.13.0",
63
63
  "@types/croppie": "^2.6.4",
64
- "@types/google.maps": "^3.55.4",
64
+ "@types/google.maps": "^3.58.1",
65
65
  "@types/humanize-duration": "^3.27.4",
66
66
  "@types/lodash": "^4.17.13",
67
67
  "@types/luxon": "^3.4.2",
@@ -1,4 +1,5 @@
1
1
  import { BaseAddressForm } from "."
2
+ import { config } from "..";
2
3
  import ShowValue from "../../.storybook/components/ShowValue.vue";
3
4
  import { sizes } from "../../.storybook/utils";
4
5
 
@@ -112,3 +113,27 @@ export const RestrictCountry = DefaultCountryAndRegionTemplate.bind({});
112
113
  RestrictCountry.args = {
113
114
  restrictCountry: true,
114
115
  };
116
+
117
+ // Force a Google Places API instead of detecting it (`auto`).
118
+ // Requires both Places API (New) and Places API (Legacy) on the Storybook key.
119
+ const ForcedPlacesApiTemplate = (googlePlacesApi) => (args) => ({
120
+ components: { BaseAddressForm, ShowValue },
121
+ setup() {
122
+ config.googlePlacesApi = googlePlacesApi;
123
+ onUnmounted(() => {
124
+ config.googlePlacesApi = "auto";
125
+ });
126
+ const form = ref({ shipping_address: { country: "ca" } });
127
+ return { args, form };
128
+ },
129
+ template: `
130
+ <BaseAddressForm v-model="form.shipping_address" prefix="shipping_address" v-bind="args"></BaseAddressForm>
131
+ <ShowValue :value="form" />
132
+ `,
133
+ });
134
+
135
+ export const ForceNew = ForcedPlacesApiTemplate("new").bind({});
136
+ ForceNew.args = {};
137
+
138
+ export const ForceLegacy = ForcedPlacesApiTemplate("legacy").bind({});
139
+ ForceLegacy.args = {};
@@ -19,10 +19,51 @@
19
19
  prevent-submit
20
20
  name="address_search_field"
21
21
  :autocomplete="false"
22
- @update:model-value="update('address_1', $event)"
22
+ @update:model-value="onAddress1Input"
23
+ @keydown="onAddress1Keydown"
23
24
  />
24
25
  </BaseField>
25
26
 
27
+ <Teleport to="body">
28
+ <div
29
+ v-if="suggestionsOpened"
30
+ ref="suggestionsRef"
31
+ :style="{ ...floatingStyles, width: address1InputWidth + 'px' }"
32
+ class="fixed z-menu overflow-hidden border border-slate-300 bg-white shadow-2xl input-rounded"
33
+ >
34
+ <BaseAutocompleteDrawer
35
+ ref="suggestionsDrawerRef"
36
+ :options="suggestionOptions"
37
+ :size="size"
38
+ :keywords="normalizedModelValue.address_1 ?? ''"
39
+ tw-drawer="p-1"
40
+ @select="onSuggestionSelect"
41
+ >
42
+ <template #option="{ option, active }">
43
+ <div
44
+ class="rounded px-[0.75em] py-[0.4em] text-sm"
45
+ :class="active ? 'bg-slate-200' : 'bg-white'"
46
+ >
47
+ <p class="block font-semibold">
48
+ {{ option?.mainText }}
49
+ </p>
50
+ <small class="-mt-[0.075em] block text-xs text-slate-500">
51
+ {{ option?.secondaryText }}
52
+ </small>
53
+ </div>
54
+ </template>
55
+ <template #footer>
56
+ <div class="flex justify-end border-t border-slate-200 px-2.5 pb-1.5 pt-2.5">
57
+ <BaseGoogleMapsLogo
58
+ class="h-4"
59
+ translate="no"
60
+ />
61
+ </div>
62
+ </template>
63
+ </BaseAutocompleteDrawer>
64
+ </div>
65
+ </Teleport>
66
+
26
67
  <BaseField
27
68
  :name="`${namePrefix}address_2`"
28
69
  class="col-span-2"
@@ -123,7 +164,7 @@
123
164
 
124
165
  <script lang="ts" setup>
125
166
  import { ComputedRef } from 'vue';
126
- import { cloneDeep, isArray } from 'lodash';
167
+ import { cloneDeep, debounce, isArray } from 'lodash';
127
168
  import { Country } from '@/types/Country';
128
169
  import { Region } from '@/types/Region';
129
170
  import BaseField from './BaseField.vue';
@@ -134,6 +175,18 @@ import { t } from '@/i18n';
134
175
  import { config } from '..';
135
176
  import { Size } from '@/utils/sizes';
136
177
  import { useInputSize } from '@/composables/inputSize';
178
+ import { useClickOutside } from '@/composables/clickOutside';
179
+ import {
180
+ normalizeLegacyAddressComponents,
181
+ PlaceAddressComponent,
182
+ PlaceSuggestion,
183
+ usePlacesAutocomplete,
184
+ } from '@/composables/placesAutocomplete';
185
+ import { NormalizedOption } from '@/types';
186
+ import BaseAutocompleteDrawer from './BaseAutocompleteDrawer.vue';
187
+ import BaseGoogleMapsLogo from '../svg/BaseGoogleMapsLogo.vue';
188
+ import { autoUpdate, flip, offset, useFloating } from '@floating-ui/vue';
189
+ import { useElementBounding } from '@vueuse/core';
137
190
 
138
191
  const DEFAULT_COUNTRY = 'ca';
139
192
 
@@ -253,105 +306,250 @@ const address1Input = computed(
253
306
  () => address1Ref.value?.$refs.input ?? null
254
307
  ) as ComputedRef<HTMLInputElement | null>;
255
308
 
309
+ const MIN_SEARCH_LENGTH = 3;
310
+
311
+ const placesRegionCode = computed((): string | null => {
312
+ if (props.restrictCountry && normalizedCountry.value) {
313
+ return normalizedCountry.value;
314
+ }
315
+ return null;
316
+ });
317
+
318
+ const places = usePlacesAutocomplete(placesRegionCode, config.googlePlacesApi);
319
+
320
+ // Places API (New): suggestions are shown in our own dropdown
321
+
322
+ const suggestions = ref<PlaceSuggestion[]>([]);
323
+ const suggestionsRef = ref<HTMLElement | null>(null);
324
+ const suggestionsDrawerRef = ref<InstanceType<typeof BaseAutocompleteDrawer> | null>(null);
325
+
326
+ const suggestionsOpened = computed(() => suggestions.value.length > 0);
327
+
328
+ const suggestionOptions = computed((): NormalizedOption[] => {
329
+ return suggestions.value.map((suggestion) => ({
330
+ value: suggestion.id,
331
+ label: suggestion.mainText,
332
+ option: suggestion,
333
+ }));
334
+ });
335
+
336
+ function onAddress1Input(value: string) {
337
+ update('address_1', value);
338
+
339
+ if (places.usesNewApi.value) {
340
+ places.cancelPendingSuggestions();
341
+ searchSuggestions(value);
342
+ }
343
+
344
+ // The Google Maps script may have loaded after the form was mounted
345
+ if (places.usesLegacyApi.value && !legacyAutocomplete) {
346
+ attachLegacyAutocompleteAndReplay();
347
+ }
348
+ }
349
+
350
+ const searchSuggestions = debounce(async (value: string) => {
351
+ if (value.trim().length < MIN_SEARCH_LENGTH) {
352
+ closeSuggestions();
353
+ return;
354
+ }
355
+
356
+ const result = await places.fetchSuggestions(value);
357
+
358
+ if (result !== null) {
359
+ suggestions.value = result;
360
+ }
361
+ }, 300);
362
+
363
+ function closeSuggestions() {
364
+ searchSuggestions.cancel();
365
+ places.cancelPendingSuggestions();
366
+ suggestions.value = [];
367
+ }
368
+
369
+ function onAddress1Keydown(event: KeyboardEvent) {
370
+ if (!suggestionsOpened.value) {
371
+ return;
372
+ }
373
+
374
+ if (event.key === 'Escape') {
375
+ // Only close the suggestions, not a dialog containing the form
376
+ event.preventDefault();
377
+ event.stopPropagation();
378
+ closeSuggestions();
379
+ return;
380
+ }
381
+
382
+ if (event.key === 'Tab') {
383
+ closeSuggestions();
384
+ return;
385
+ }
386
+
387
+ if (['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) {
388
+ event.preventDefault();
389
+ suggestionsDrawerRef.value?.onKeydown(event);
390
+ }
391
+ }
392
+
393
+ async function onSuggestionSelect(option: NormalizedOption) {
394
+ closeSuggestions();
395
+ const components = await places.fetchAddressComponents(option.option as PlaceSuggestion);
396
+ fillAddress(components);
397
+ }
398
+
399
+ useClickOutside(suggestionsRef, closeSuggestions, {
400
+ includes: () => [address1Input],
401
+ });
402
+
403
+ const address1InputRect = useElementBounding(address1Input);
404
+ const address1InputWidth = computed(() => address1InputRect.width.value);
405
+
406
+ const { floatingStyles } = useFloating(address1Input, suggestionsRef, {
407
+ placement: 'bottom-start',
408
+ middleware: [offset(4), flip()],
409
+ whileElementsMounted: autoUpdate,
410
+ });
411
+
412
+ // Legacy Places API: Google's Autocomplete widget is attached to the input
413
+
256
414
  // eslint-disable-next-line no-undef
257
- let autocomplete = null as google.maps.places.Autocomplete | null;
415
+ let legacyAutocomplete = null as google.maps.places.Autocomplete | null;
258
416
 
259
417
  onMounted(() => {
260
- if (!window.google) {
261
- return;
418
+ if (places.usesLegacyApi.value) {
419
+ attachLegacyAutocomplete();
420
+ }
421
+ });
422
+
423
+ // In `auto` mode, the API is known after the first request
424
+ watch(places.api, (api) => {
425
+ if (api === 'legacy') {
426
+ closeSuggestions();
427
+ attachLegacyAutocompleteAndReplay();
428
+ }
429
+ if (api === 'unavailable') {
430
+ closeSuggestions();
262
431
  }
432
+ });
433
+
434
+ let legacyReplayTimeout = null as ReturnType<typeof setTimeout> | null;
263
435
 
264
- if (!address1Input.value) {
436
+ function attachLegacyAutocompleteAndReplay() {
437
+ if (!attachLegacyAutocomplete()) {
265
438
  return;
266
439
  }
267
440
 
268
- autocomplete = new window.google.maps.places.Autocomplete(
441
+ // The widget only reacts to input typed after it was attached, so replay the
442
+ // current value to show suggestions right away. It finishes its setup after
443
+ // the current task: replaying synchronously leaves its dropdown hidden.
444
+ // Only the field being typed in: other forms on the page are left untouched.
445
+ legacyReplayTimeout = setTimeout(() => {
446
+ const input = address1Input.value;
447
+ if (input && document.activeElement === input) {
448
+ // The widget only shows its dropdown after it saw the field gain focus
449
+ input.dispatchEvent(new FocusEvent('focus'));
450
+ input.dispatchEvent(new Event('input', { bubbles: true }));
451
+ }
452
+ });
453
+ }
454
+
455
+ onBeforeUnmount(() => {
456
+ if (legacyReplayTimeout) {
457
+ clearTimeout(legacyReplayTimeout);
458
+ }
459
+ });
460
+
461
+ function attachLegacyAutocomplete(): boolean {
462
+ if (legacyAutocomplete || !address1Input.value || !window.google?.maps) {
463
+ return false;
464
+ }
465
+
466
+ if (!window.google.maps.places) {
467
+ console.warn('[BaseAddressForm] The Google Maps script must be loaded with `libraries=places`. Address autocomplete is disabled.');
468
+ return false;
469
+ }
470
+
471
+ legacyAutocomplete = new window.google.maps.places.Autocomplete(
269
472
  address1Input.value
270
473
  );
271
474
 
272
- setAutocompleteOptions();
475
+ setLegacyAutocompleteOptions();
273
476
 
274
- autocomplete.addListener('place_changed', fillAddress);
275
- });
477
+ legacyAutocomplete.addListener('place_changed', () => {
478
+ const place = legacyAutocomplete?.getPlace();
479
+ if (!place?.address_components) {
480
+ return;
481
+ }
482
+ fillAddress(normalizeLegacyAddressComponents(place.address_components));
483
+ });
276
484
 
277
- watch(
278
- () => props.modelValue?.country + ' ' + props.restrictCountry,
279
- () => {
280
- setAutocompleteOptions();
281
- }
282
- );
485
+ return true;
486
+ }
283
487
 
284
- function setAutocompleteOptions() {
285
- if (!autocomplete) {
488
+ watch(placesRegionCode, () => {
489
+ setLegacyAutocompleteOptions();
490
+ });
491
+
492
+ function setLegacyAutocompleteOptions() {
493
+ if (!legacyAutocomplete) {
286
494
  return;
287
495
  }
288
496
 
289
- const options = {
497
+ // eslint-disable-next-line no-undef
498
+ const options: google.maps.places.AutocompleteOptions = {
290
499
  fields: ['address_components'],
291
500
  types: ['address'],
292
- } as any;
501
+ };
293
502
 
294
- if (props.restrictCountry) {
295
- if (normalizedCountry.value) {
296
- options.componentRestrictions = {
297
- country: normalizedCountry.value,
298
- };
299
- }
503
+ if (placesRegionCode.value) {
504
+ options.componentRestrictions = {
505
+ country: placesRegionCode.value,
506
+ };
300
507
  }
301
508
 
302
- autocomplete.setOptions(options);
509
+ legacyAutocomplete.setOptions(options);
303
510
  }
304
511
 
305
- function fillAddress() {
306
- if (!autocomplete) {
512
+ function fillAddress(components: PlaceAddressComponent[]) {
513
+ if (components.length === 0) {
307
514
  return;
308
515
  }
309
516
 
310
- // Get the place details from the autocomplete object.
311
- const place = autocomplete.getPlace();
312
517
  let address1 = '';
313
518
  let postcode = '';
314
519
  let region = '';
315
520
 
316
521
  const newForm = cloneDeep(props.modelValue ?? {});
317
522
 
318
- if (!place.address_components) {
319
- return;
320
- }
321
-
322
523
  // Get each component of the address from the place details,
323
524
  // and then fill-in the corresponding field on the form.
324
- // place.address_components are google.maps.GeocoderAddressComponent objects
325
- // which are documented at http://goo.gle/3l5i5Mr
326
- // eslint-disable-next-line no-undef
327
- for (const component of place.address_components as google.maps.GeocoderAddressComponent[]) {
525
+ for (const component of components) {
328
526
  const componentType = component.types[0];
329
527
  switch (componentType) {
330
528
  case 'street_number': {
331
- address1 = `${component.long_name} ${address1}`;
529
+ address1 = `${component.longText} ${address1}`;
332
530
  break;
333
531
  }
334
532
  case 'route': {
335
- address1 += component.long_name;
533
+ address1 += component.longText;
336
534
  break;
337
535
  }
338
536
  case 'postal_code': {
339
- postcode = `${component.long_name}${postcode}`;
537
+ postcode = `${component.longText}${postcode}`;
340
538
  break;
341
539
  }
342
540
  case 'postal_code_suffix': {
343
- postcode = `${postcode}-${component.long_name}`;
541
+ postcode = `${postcode}-${component.longText}`;
344
542
  break;
345
543
  }
346
544
  case 'locality':
347
- newForm.city = component.long_name;
545
+ newForm.city = component.longText;
348
546
  break;
349
547
  case 'administrative_area_level_1': {
350
- region = component.short_name.toLowerCase();
548
+ region = component.shortText.toLowerCase();
351
549
  break;
352
550
  }
353
551
  case 'country':
354
- newForm.country = component.short_name.toLowerCase();
552
+ newForm.country = component.shortText.toLowerCase();
355
553
  break;
356
554
  }
357
555
  }
@@ -361,11 +559,6 @@ function fillAddress() {
361
559
  newForm.postal_code = postcode;
362
560
 
363
561
  nextTick(() => {
364
- // Force value change
365
- /* if (address1Input.value) {
366
- address1Input.value.value = address1;
367
- } */
368
-
369
562
  emit('update:model-value', newForm);
370
563
  });
371
564
  }