@leavepulse/ui 0.16.0 → 0.17.0

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.
Files changed (38) hide show
  1. package/dist/chunks/LpAddressInput-D29vSoNd.js +366 -0
  2. package/dist/chunks/{LpConfirmDialog-C-iOXJIM.js → LpConfirmDialog-DnReh5pF.js} +1 -1
  3. package/dist/chunks/LpMapPicker-DFKrd1Ne.js +509 -0
  4. package/dist/chunks/{LpModal-DeZlvK1B.js → LpModal-CezA8eJ3.js} +9 -6
  5. package/dist/chunks/{LpNotificationBell-BtfRoL2i.js → LpNotificationBell-CDixx44E.js} +25 -12
  6. package/dist/chunks/{LpSelect-DznhPEWX.js → LpSelect-D5mU4cZO.js} +5 -1
  7. package/dist/chunks/{LpThemeSwitcher-BgWwfkxs.js → LpThemeSwitcher-DC6QygnU.js} +11 -3
  8. package/dist/component-names.d.ts +1 -1
  9. package/dist/components/LpAddressInput.vue.d.ts +53 -0
  10. package/dist/components/LpAddressInput.vue.js +4 -0
  11. package/dist/components/LpAlert.vue.d.ts +1 -1
  12. package/dist/components/LpConfirmDialog.vue.js +1 -1
  13. package/dist/components/LpFileTree.vue.d.ts +2 -2
  14. package/dist/components/LpLightbox.vue.d.ts +1 -1
  15. package/dist/components/LpMapPicker.vue.d.ts +44 -0
  16. package/dist/components/LpMapPicker.vue.js +4 -0
  17. package/dist/components/LpModal.vue.js +1 -1
  18. package/dist/components/LpNotificationBell.vue.d.ts +22 -0
  19. package/dist/components/LpNotificationBell.vue.js +1 -1
  20. package/dist/components/LpSelect.vue.js +1 -1
  21. package/dist/components/LpThemeSwitcher.vue.d.ts +7 -0
  22. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  23. package/dist/components/address.d.ts +107 -0
  24. package/dist/components/address.js +96 -0
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.js +129 -117
  27. package/package.json +1 -1
  28. package/src/component-names.ts +2 -0
  29. package/src/components/LpAddressInput.vue +377 -0
  30. package/src/components/LpMapPicker.vue +665 -0
  31. package/src/components/LpModal.vue +15 -0
  32. package/src/components/LpNotificationBell.vue +47 -9
  33. package/src/components/LpSelect.vue +0 -0
  34. package/src/components/LpThemeSwitcher.vue +24 -2
  35. package/src/components/address.ts +250 -0
  36. package/src/index.ts +26 -1
  37. package/src/theme/presets.ts +8 -2
  38. package/src/tokens/tokens.css +18 -0
@@ -0,0 +1,377 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Postal address form — street, optional second line, city, postcode and
4
+ * country — with live suggestions on the text fields and a button that opens a
5
+ * map to drop a pin instead of typing.
6
+ *
7
+ * Everything external is swappable. `provider` is any GeocodeProvider (default:
8
+ * public Nominatim), `tiles` any {z}/{x}/{y} server (default: OSM), and the
9
+ * `map` slot replaces the built-in picker outright for apps that already ship
10
+ * Leaflet or MapLibre. Nothing map-related is imported until the dialog opens:
11
+ * the picker is an async component, so the tile maths stays out of the bundle
12
+ * of every form that never shows a map.
13
+ */
14
+ import { computed, defineAsyncComponent, onMounted, ref, watch } from "vue"
15
+ import {
16
+ type AddressField,
17
+ type AddressLabels,
18
+ type AddressSuggestion,
19
+ type AddressValue,
20
+ type GeocodeProvider,
21
+ type LatLon,
22
+ OSM_TILES,
23
+ type TileSource,
24
+ createNominatimProvider,
25
+ emptyAddress,
26
+ formatAddress,
27
+ } from "./address"
28
+ import { type Country, flagEmoji, loadCountries } from "./countries"
29
+ import LpAutocomplete from "./LpAutocomplete.vue"
30
+ import LpButton from "./LpButton.vue"
31
+ import LpFormField from "./LpFormField.vue"
32
+ import LpIcon from "./LpIcon.vue"
33
+ import LpInput from "./LpInput.vue"
34
+ import LpModal from "./LpModal.vue"
35
+ import LpSelect from "./LpSelect.vue"
36
+
37
+ const LpMapPicker = defineAsyncComponent(() => import("./LpMapPicker.vue"))
38
+
39
+ const props = withDefaults(
40
+ defineProps<{
41
+ /** The address. Bind with v-model. */
42
+ modelValue?: AddressValue
43
+ /** Geocoder for suggestions and for the map's reverse lookup. */
44
+ provider?: GeocodeProvider
45
+ /** Tile server handed to the built-in picker. */
46
+ tiles?: TileSource
47
+ /** Restrict the country list (ISO2). Omit for every country. */
48
+ only?: string[]
49
+ /** Show the "apartment, suite" line. */
50
+ secondLine?: boolean
51
+ /** Suggestions start after this many characters. */
52
+ minChars?: number
53
+ /** Debounce before a suggestion request goes out. */
54
+ debounce?: number
55
+ /** Accept-Language for the geocoder. */
56
+ language?: string
57
+ disabled?: boolean
58
+ size?: "sm" | "md" | "lg"
59
+ /** Hide the map button when picking a point makes no sense. */
60
+ hideMap?: boolean
61
+ labels?: Partial<AddressLabels>
62
+ }>(),
63
+ {
64
+ tiles: () => OSM_TILES,
65
+ minChars: 3,
66
+ debounce: 350,
67
+ size: "md",
68
+ },
69
+ )
70
+
71
+ const emit = defineEmits<{
72
+ (e: "update:modelValue", value: AddressValue): void
73
+ /** A point was confirmed on the map, after the address was filled in. */
74
+ (e: "pick", value: LatLon): void
75
+ }>()
76
+
77
+ const DEFAULT_LABELS: AddressLabels = {
78
+ line: "Street and number",
79
+ linePlaceholder: "Baker Street 221B",
80
+ secondLine: "Apartment, suite (optional)",
81
+ secondLinePlaceholder: "Flat 4",
82
+ city: "City",
83
+ cityPlaceholder: "London",
84
+ postalCode: "Postcode",
85
+ postalCodePlaceholder: "NW1 6XE",
86
+ country: "Country",
87
+ countryPlaceholder: "Select country…",
88
+ countrySearch: "Search country…",
89
+ pickOnMap: "Pick on map",
90
+ mapTitle: "Pick a location",
91
+ mapHint: "Click the map to drop a pin, drag to move around.",
92
+ use: "Use this address",
93
+ cancel: "Cancel",
94
+ searching: "Searching…",
95
+ noSuggestions: "No suggestions",
96
+ nothingHere: "No address found here",
97
+ }
98
+
99
+ const text = computed<AddressLabels>(() => ({ ...DEFAULT_LABELS, ...props.labels }))
100
+
101
+ // One provider instance for the component's lifetime, so a parent that passes
102
+ // none doesn't get a new Nominatim client on every render.
103
+ const fallbackProvider = createNominatimProvider()
104
+ const geocoder = computed(() => props.provider ?? fallbackProvider)
105
+
106
+ const value = computed<AddressValue>(() => props.modelValue ?? emptyAddress())
107
+
108
+ function patch(changes: Partial<AddressValue>) {
109
+ emit("update:modelValue", { ...value.value, ...changes })
110
+ }
111
+
112
+ const countries = ref<Country[]>([])
113
+ const countryOptions = computed(() =>
114
+ countries.value.map((country) => ({
115
+ value: country.iso2,
116
+ label: `${flagEmoji(country.iso2)} ${country.name}`,
117
+ })),
118
+ )
119
+
120
+ onMounted(async () => {
121
+ const list = await loadCountries()
122
+ const only = props.only?.map((code) => code.toUpperCase())
123
+ countries.value = only?.length ? list.filter((c) => only.includes(c.iso2)) : list
124
+ })
125
+
126
+ /*
127
+ * Suggestions for one text field. Each field keeps its own list and in-flight
128
+ * request; a newer keystroke aborts the previous fetch so a slow response can
129
+ * never overwrite a fresher one.
130
+ */
131
+ function useSuggestions(field: AddressField) {
132
+ const options = ref<AddressSuggestion[]>([])
133
+ const loading = ref(false)
134
+ let timer: ReturnType<typeof setTimeout> | undefined
135
+ let controller: AbortController | undefined
136
+
137
+ function query(term: string) {
138
+ if (timer) clearTimeout(timer)
139
+ controller?.abort()
140
+ if (term.trim().length < props.minChars) {
141
+ options.value = []
142
+ loading.value = false
143
+ return
144
+ }
145
+ loading.value = true
146
+ timer = setTimeout(async () => {
147
+ controller = new AbortController()
148
+ try {
149
+ options.value = await geocoder.value.search(term.trim(), field, {
150
+ country: value.value.country || undefined,
151
+ language: props.language,
152
+ signal: controller.signal,
153
+ })
154
+ } catch {
155
+ // Abort or network failure: leave the field usable as free text.
156
+ options.value = []
157
+ } finally {
158
+ loading.value = false
159
+ }
160
+ }, props.debounce)
161
+ }
162
+
163
+ return { options, loading, query }
164
+ }
165
+
166
+ const lineSuggestions = useSuggestions("line")
167
+ const citySuggestions = useSuggestions("city")
168
+
169
+ /** Take whatever parts a chosen suggestion resolved, keeping typed-in values. */
170
+ function applySuggestion(suggestion: AddressSuggestion, keep: keyof AddressValue) {
171
+ const parsed = suggestion.address
172
+ const changes: Partial<AddressValue> = { lat: suggestion.lat, lon: suggestion.lon }
173
+ if (parsed.line && keep !== "line") changes.line = parsed.line
174
+ if (parsed.city) changes.city = parsed.city
175
+ if (parsed.postalCode) changes.postalCode = parsed.postalCode
176
+ if (parsed.country) changes.country = parsed.country
177
+ patch(changes)
178
+ }
179
+
180
+ function onLineSelect(chosen: string) {
181
+ const suggestion = lineSuggestions.options.value.find((option) => option.value === chosen)
182
+ if (suggestion) applySuggestion(suggestion, "line")
183
+ }
184
+
185
+ function onCitySelect(chosen: string) {
186
+ const suggestion = citySuggestions.options.value.find((option) => option.value === chosen)
187
+ if (suggestion) applySuggestion(suggestion, "city")
188
+ }
189
+
190
+ const mapOpen = ref(false)
191
+ const point = ref<LatLon | undefined>()
192
+ const resolved = ref<AddressSuggestion | undefined>()
193
+ const resolving = ref(false)
194
+
195
+ const countryName = computed(
196
+ () => countries.value.find((c) => c.iso2 === value.value.country)?.name,
197
+ )
198
+
199
+ const preview = computed(() => {
200
+ if (resolving.value) return text.value.searching
201
+ if (!point.value) return text.value.mapHint
202
+ if (!resolved.value) return text.value.nothingHere
203
+ const parsed = resolved.value.address
204
+ return formatAddress(
205
+ {
206
+ line: parsed.line ?? "",
207
+ city: parsed.city ?? "",
208
+ postalCode: parsed.postalCode ?? "",
209
+ country: parsed.country ?? "",
210
+ },
211
+ countries.value.find((c) => c.iso2 === parsed.country)?.name,
212
+ )
213
+ })
214
+
215
+ watch(mapOpen, (open) => {
216
+ if (!open) return
217
+ // Reopening starts from the address already on the form, so the map lands
218
+ // where the user left off rather than in the middle of Europe.
219
+ point.value = value.value.lat != null && value.value.lon != null
220
+ ? { lat: value.value.lat, lon: value.value.lon }
221
+ : undefined
222
+ resolved.value = undefined
223
+ })
224
+
225
+ async function onPick(next: LatLon) {
226
+ point.value = next
227
+ resolving.value = true
228
+ resolved.value = undefined
229
+ try {
230
+ resolved.value = await geocoder.value.reverse(next.lat, next.lon, {
231
+ language: props.language,
232
+ })
233
+ } catch {
234
+ resolved.value = undefined
235
+ } finally {
236
+ resolving.value = false
237
+ }
238
+ }
239
+
240
+ /** Confirm the pin: fill in whatever came back and always keep the point. */
241
+ function confirmPoint() {
242
+ if (!point.value) return
243
+ const parsed = resolved.value?.address ?? {}
244
+ patch({
245
+ line: parsed.line ?? value.value.line,
246
+ city: parsed.city ?? value.value.city,
247
+ postalCode: parsed.postalCode ?? value.value.postalCode,
248
+ country: parsed.country ?? value.value.country,
249
+ lat: point.value.lat,
250
+ lon: point.value.lon,
251
+ })
252
+ emit("pick", point.value)
253
+ mapOpen.value = false
254
+ }
255
+ </script>
256
+
257
+ <template>
258
+ <div class="flex flex-col gap-3">
259
+ <LpFormField :label="text.line">
260
+ <!-- Typing is the fast path and stands on its own; the map is one button
261
+ away for when an address is easier pointed at than spelled. -->
262
+ <div class="flex items-stretch gap-2">
263
+ <LpAutocomplete
264
+ class="min-w-0 flex-1"
265
+ :model-value="value.line"
266
+ :options="lineSuggestions.options.value"
267
+ :loading="lineSuggestions.loading.value"
268
+ :filter="false"
269
+ :min-chars="minChars"
270
+ :disabled="disabled"
271
+ :size="size"
272
+ :placeholder="text.linePlaceholder"
273
+ :empty-text="text.noSuggestions"
274
+ :loading-text="text.searching"
275
+ icon="lucide:map-pin"
276
+ @update:model-value="(next: string) => { patch({ line: next }); lineSuggestions.query(next) }"
277
+ @select="onLineSelect"
278
+ />
279
+ <LpButton
280
+ v-if="!hideMap"
281
+ variant="soft"
282
+ :size="size"
283
+ square
284
+ :disabled="disabled"
285
+ :title="text.pickOnMap"
286
+ :aria-label="text.pickOnMap"
287
+ @click="mapOpen = true"
288
+ >
289
+ <LpIcon name="lucide:map" :size="16" />
290
+ </LpButton>
291
+ </div>
292
+ </LpFormField>
293
+
294
+ <LpInput
295
+ v-if="secondLine"
296
+ :model-value="value.line2 ?? ''"
297
+ :label="text.secondLine"
298
+ :placeholder="text.secondLinePlaceholder"
299
+ :disabled="disabled"
300
+ :size="size"
301
+ autocomplete="address-line2"
302
+ @update:model-value="(next: string) => patch({ line2: next })"
303
+ />
304
+
305
+ <div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem]">
306
+ <LpFormField :label="text.city">
307
+ <LpAutocomplete
308
+ :model-value="value.city"
309
+ :options="citySuggestions.options.value"
310
+ :loading="citySuggestions.loading.value"
311
+ :filter="false"
312
+ :min-chars="minChars"
313
+ :disabled="disabled"
314
+ :size="size"
315
+ :placeholder="text.cityPlaceholder"
316
+ :empty-text="text.noSuggestions"
317
+ :loading-text="text.searching"
318
+ @update:model-value="(next: string) => { patch({ city: next }); citySuggestions.query(next) }"
319
+ @select="onCitySelect"
320
+ />
321
+ </LpFormField>
322
+
323
+ <LpInput
324
+ :model-value="value.postalCode"
325
+ :label="text.postalCode"
326
+ :placeholder="text.postalCodePlaceholder"
327
+ :disabled="disabled"
328
+ :size="size"
329
+ autocomplete="postal-code"
330
+ @update:model-value="(next: string) => patch({ postalCode: next })"
331
+ />
332
+ </div>
333
+
334
+ <LpFormField :label="text.country">
335
+ <LpSelect
336
+ :model-value="value.country"
337
+ :options="countryOptions"
338
+ :placeholder="text.countryPlaceholder"
339
+ :search-placeholder="text.countrySearch"
340
+ :disabled="disabled"
341
+ :size="size"
342
+ searchable
343
+ @update:model-value="(next) => patch({ country: String(next ?? '') })"
344
+ />
345
+ </LpFormField>
346
+
347
+ <div v-if="!hideMap" class="flex items-center gap-2">
348
+ <LpButton variant="soft" :size="size === 'lg' ? 'md' : 'sm'" :disabled="disabled" @click="mapOpen = true">
349
+ <LpIcon name="lucide:map" :size="15" />
350
+ {{ text.pickOnMap }}
351
+ </LpButton>
352
+ <span v-if="value.lat != null && value.lon != null" class="text-xs text-muted">
353
+ {{ value.lat.toFixed(5) }}, {{ value.lon.toFixed(5) }}
354
+ </span>
355
+ </div>
356
+
357
+ <LpModal v-model:open="mapOpen" :title="text.mapTitle" size="2xl">
358
+ <!--
359
+ Replace the whole picker with your own map. The slot gets the current
360
+ point and a setter that runs the same reverse lookup the built-in map
361
+ does, so a Leaflet or MapLibre implementation only has to report clicks.
362
+ -->
363
+ <slot name="map" :point="point" :set-point="onPick" :tiles="tiles">
364
+ <LpMapPicker :model-value="point" :tiles="tiles" @update:model-value="onPick" />
365
+ </slot>
366
+
367
+ <p class="mt-3 min-h-5 text-sm" :class="resolved || resolving ? 'text-ink' : 'text-muted'">
368
+ {{ preview }}
369
+ </p>
370
+
371
+ <template #footer>
372
+ <LpButton variant="ghost" @click="mapOpen = false">{{ text.cancel }}</LpButton>
373
+ <LpButton :disabled="!point" @click="confirmPoint">{{ text.use }}</LpButton>
374
+ </template>
375
+ </LpModal>
376
+ </div>
377
+ </template>