@leavepulse/ui 0.16.2 → 0.17.1
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.
- package/dist/chunks/LpAddressInput-ChT1JQg0.js +366 -0
- package/dist/chunks/{LpAppShell-CV194os6.js → LpAppShell-C6Mj82LS.js} +2 -2
- package/dist/chunks/{LpConfirmDialog-DnReh5pF.js → LpConfirmDialog-3_Qu4hnA.js} +1 -1
- package/dist/chunks/{LpDrawer-BHzCy-Aa.js → LpDrawer-Fxk7ViQ-.js} +1 -1
- package/dist/chunks/{LpFileTree-YOJCuIjJ.js → LpFileTree-CXg3KDth.js} +1 -1
- package/dist/chunks/{LpLogViewer-BvzRZPjs.js → LpLogViewer-D7DNEYaG.js} +1 -1
- package/dist/chunks/LpMapPicker-DFKrd1Ne.js +509 -0
- package/dist/chunks/{LpModal-CezA8eJ3.js → LpModal-J-KRPO7F.js} +1 -1
- package/dist/chunks/{LpNotificationBell-BtfRoL2i.js → LpNotificationBell-D8sdQLgu.js} +26 -13
- package/dist/chunks/{LpScrollArea-De9DLpa1.js → LpScrollArea-CKhOx0_l.js} +2 -2
- package/dist/chunks/{LpSidebar-B11IDRQi.js → LpSidebar-Bk7SrINq.js} +2 -2
- package/dist/chunks/{LpSidebarNav-TIUNHZX-.js → LpSidebarNav-Cpd-yHXb.js} +1 -1
- package/dist/chunks/{LpTable-CKGaUbEM.js → LpTable-X3ieZmbr.js} +1 -1
- package/dist/chunks/{LpThemeSwitcher-BgWwfkxs.js → LpThemeSwitcher-DC6QygnU.js} +11 -3
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAddressInput.vue.d.ts +53 -0
- package/dist/components/LpAddressInput.vue.js +4 -0
- package/dist/components/LpAlert.vue.d.ts +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +1 -1
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpFileTree.vue.d.ts +2 -2
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpLightbox.vue.d.ts +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.d.ts +44 -0
- package/dist/components/LpMapPicker.vue.js +4 -0
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.d.ts +22 -0
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpScrollArea.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpTable.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.d.ts +7 -0
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/address.d.ts +107 -0
- package/dist/components/address.js +96 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +129 -117
- package/package.json +1 -1
- package/src/component-names.ts +2 -0
- package/src/components/LpAddressInput.vue +377 -0
- package/src/components/LpMapPicker.vue +665 -0
- package/src/components/LpNotificationBell.vue +47 -9
- package/src/components/LpScrollArea.vue +8 -2
- package/src/components/LpThemeSwitcher.vue +24 -2
- package/src/components/address.ts +250 -0
- package/src/index.ts +194 -169
- package/src/theme/presets.ts +8 -2
|
@@ -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>
|